Skip to content

Commit ddacfc2

Browse files
authored
Merge pull request #201 from SentienceAPI/debugger
Sentience Debugger for any agent
2 parents 98e62c1 + 66530dd commit ddacfc2

File tree

5 files changed

+230
-0
lines changed

5 files changed

+230
-0
lines changed

sentience/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from .cloud_tracing import CloudTraceSink, SentienceLogger
6464
from .conversational_agent import ConversationalAgent
6565
from .cursor_policy import CursorPolicy
66+
from .debugger import SentienceDebugger
6667
from .expect import expect
6768
from .generator import ScriptGenerator, generate
6869
from .inspector import Inspector, inspect
@@ -286,6 +287,7 @@
286287
"AgentAction",
287288
# Verification (agent assertion loop)
288289
"AgentRuntime",
290+
"SentienceDebugger",
289291
"AssertContext",
290292
"AssertOutcome",
291293
"Predicate",

sentience/agent_runtime.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,59 @@ def __init__(
185185
self._captcha_options: CaptchaOptions | None = None
186186
self._captcha_retry_count: int = 0
187187

188+
@classmethod
189+
def from_playwright_page(
190+
cls,
191+
page: Page,
192+
tracer: Tracer,
193+
snapshot_options: SnapshotOptions | None = None,
194+
sentience_api_key: str | None = None,
195+
tool_registry: ToolRegistry | None = None,
196+
) -> AgentRuntime:
197+
"""
198+
Create AgentRuntime from a raw Playwright Page (sidecar mode).
199+
200+
Args:
201+
page: Playwright Page for browser interaction
202+
tracer: Tracer for emitting verification events
203+
snapshot_options: Default options for snapshots
204+
sentience_api_key: API key for Pro/Enterprise tier
205+
tool_registry: Optional ToolRegistry for LLM-callable tools
206+
207+
Returns:
208+
AgentRuntime instance
209+
"""
210+
from .backends.playwright_backend import PlaywrightBackend
211+
212+
backend = PlaywrightBackend(page)
213+
return cls(
214+
backend=backend,
215+
tracer=tracer,
216+
snapshot_options=snapshot_options,
217+
sentience_api_key=sentience_api_key,
218+
tool_registry=tool_registry,
219+
)
220+
221+
@classmethod
222+
def attach(
223+
cls,
224+
page: Page,
225+
tracer: Tracer,
226+
snapshot_options: SnapshotOptions | None = None,
227+
sentience_api_key: str | None = None,
228+
tool_registry: ToolRegistry | None = None,
229+
) -> AgentRuntime:
230+
"""
231+
Sidecar alias for from_playwright_page().
232+
"""
233+
return cls.from_playwright_page(
234+
page=page,
235+
tracer=tracer,
236+
snapshot_options=snapshot_options,
237+
sentience_api_key=sentience_api_key,
238+
tool_registry=tool_registry,
239+
)
240+
188241
@classmethod
189242
async def from_sentience_browser(
190243
cls,

sentience/debugger.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncIterator
4+
from contextlib import asynccontextmanager
5+
from typing import TYPE_CHECKING, Any
6+
7+
from .agent_runtime import AgentRuntime
8+
from .models import SnapshotOptions
9+
from .tools import ToolRegistry
10+
11+
if TYPE_CHECKING: # pragma: no cover - type hints only
12+
from playwright.async_api import Page
13+
14+
from .tracing import Tracer
15+
else: # pragma: no cover - avoid optional runtime imports
16+
Page = Any # type: ignore
17+
Tracer = Any # type: ignore
18+
19+
20+
class SentienceDebugger:
21+
"""
22+
Verifier-only sidecar wrapper around AgentRuntime.
23+
"""
24+
25+
def __init__(self, runtime: AgentRuntime) -> None:
26+
self.runtime = runtime
27+
self._step_open = False
28+
29+
@classmethod
30+
def attach(
31+
cls,
32+
page: Page,
33+
tracer: Tracer,
34+
snapshot_options: SnapshotOptions | None = None,
35+
sentience_api_key: str | None = None,
36+
tool_registry: ToolRegistry | None = None,
37+
) -> SentienceDebugger:
38+
runtime = AgentRuntime.from_playwright_page(
39+
page=page,
40+
tracer=tracer,
41+
snapshot_options=snapshot_options,
42+
sentience_api_key=sentience_api_key,
43+
tool_registry=tool_registry,
44+
)
45+
return cls(runtime=runtime)
46+
47+
def begin_step(self, goal: str, step_index: int | None = None) -> str:
48+
self._step_open = True
49+
return self.runtime.begin_step(goal, step_index=step_index)
50+
51+
async def end_step(self, **kwargs: Any) -> dict[str, Any]:
52+
self._step_open = False
53+
return await self.runtime.emit_step_end(**kwargs)
54+
55+
@asynccontextmanager
56+
async def step(self, goal: str, step_index: int | None = None) -> AsyncIterator[None]:
57+
self.begin_step(goal, step_index=step_index)
58+
try:
59+
yield
60+
finally:
61+
await self.end_step()
62+
63+
async def snapshot(self, **kwargs: Any):
64+
return await self.runtime.snapshot(**kwargs)
65+
66+
def check(self, predicate, label: str, required: bool = False):
67+
if not self._step_open:
68+
self.begin_step(f"verify:{label}")
69+
return self.runtime.check(predicate, label, required=required)

tests/test_agent_runtime.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,41 @@ async def test_from_sentience_browser_with_api_key(self) -> None:
695695
assert runtime._snapshot_options.use_api is True
696696

697697

698+
class TestAgentRuntimeFromPlaywrightPage:
699+
"""Tests for from_playwright_page factory method."""
700+
701+
def test_from_playwright_page_creates_runtime(self) -> None:
702+
"""Test from_playwright_page creates runtime with PlaywrightBackend."""
703+
mock_page = MagicMock()
704+
tracer = MockTracer()
705+
706+
with patch("sentience.backends.playwright_backend.PlaywrightBackend") as MockPWBackend:
707+
mock_backend_instance = MagicMock()
708+
MockPWBackend.return_value = mock_backend_instance
709+
710+
runtime = AgentRuntime.from_playwright_page(page=mock_page, tracer=tracer)
711+
712+
assert runtime.backend is mock_backend_instance
713+
assert not hasattr(runtime, "_legacy_browser")
714+
assert not hasattr(runtime, "_legacy_page")
715+
MockPWBackend.assert_called_once_with(mock_page)
716+
717+
def test_from_playwright_page_with_api_key(self) -> None:
718+
"""Test from_playwright_page passes API key."""
719+
mock_page = MagicMock()
720+
tracer = MockTracer()
721+
722+
with patch("sentience.backends.playwright_backend.PlaywrightBackend"):
723+
runtime = AgentRuntime.from_playwright_page(
724+
page=mock_page,
725+
tracer=tracer,
726+
sentience_api_key="sk_test",
727+
)
728+
729+
assert runtime._snapshot_options.sentience_api_key == "sk_test"
730+
assert runtime._snapshot_options.use_api is True
731+
732+
698733
class TestAgentRuntimeSnapshot:
699734
"""Tests for snapshot method."""
700735

tests/test_debugger.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
from contextlib import asynccontextmanager
4+
from unittest.mock import AsyncMock, MagicMock, patch
5+
6+
import pytest
7+
8+
9+
class MockRuntime:
10+
def __init__(self) -> None:
11+
self.begin_step = MagicMock(return_value="step-1")
12+
self.emit_step_end = AsyncMock(return_value={"ok": True})
13+
self.check = MagicMock(return_value="check-handle")
14+
15+
16+
class MockTracer:
17+
def emit(self, event_type: str, data: dict, step_id: str | None = None) -> None:
18+
pass
19+
20+
21+
@pytest.mark.asyncio
22+
async def test_attach_uses_runtime_factory() -> None:
23+
mock_page = MagicMock()
24+
tracer = MockTracer()
25+
runtime = MockRuntime()
26+
27+
with patch(
28+
"sentience.debugger.AgentRuntime.from_playwright_page", return_value=runtime
29+
) as mock_factory:
30+
from sentience.debugger import SentienceDebugger
31+
32+
debugger = SentienceDebugger.attach(page=mock_page, tracer=tracer)
33+
34+
mock_factory.assert_called_once_with(
35+
page=mock_page,
36+
tracer=tracer,
37+
snapshot_options=None,
38+
sentience_api_key=None,
39+
tool_registry=None,
40+
)
41+
assert debugger.runtime is runtime
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_step_context_calls_begin_and_emit() -> None:
46+
runtime = MockRuntime()
47+
48+
from sentience.debugger import SentienceDebugger
49+
50+
debugger = SentienceDebugger(runtime=runtime)
51+
52+
async with debugger.step("verify-cart"):
53+
pass
54+
55+
runtime.begin_step.assert_called_once_with("verify-cart", step_index=None)
56+
runtime.emit_step_end.assert_awaited_once()
57+
58+
59+
def test_check_auto_opens_step_when_missing() -> None:
60+
runtime = MockRuntime()
61+
62+
from sentience.debugger import SentienceDebugger
63+
64+
debugger = SentienceDebugger(runtime=runtime)
65+
predicate = MagicMock()
66+
67+
handle = debugger.check(predicate=predicate, label="has_cart", required=True)
68+
69+
runtime.begin_step.assert_called_once_with("verify:has_cart", step_index=None)
70+
runtime.check.assert_called_once_with(predicate, "has_cart", required=True)
71+
assert handle == "check-handle"

0 commit comments

Comments
 (0)