Skip to content

Commit 73e2224

Browse files
author
SentienceDEV
committed
Sentience Debugger for any agent
1 parent 98e62c1 commit 73e2224

File tree

5 files changed

+226
-0
lines changed

5 files changed

+226
-0
lines changed

sentience/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .agent import SentienceAgent, SentienceAgentAsync
3333
from .agent_config import AgentConfig
3434
from .agent_runtime import AgentRuntime, AssertionHandle
35+
from .debugger import SentienceDebugger
3536

3637
# Backend-agnostic actions (aliased to avoid conflict with existing actions)
3738
# Browser backends (for browser-use integration)
@@ -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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
from contextlib import asynccontextmanager
4+
from typing import TYPE_CHECKING, Any, AsyncIterator
5+
6+
from .agent_runtime import AgentRuntime
7+
from .models import SnapshotOptions
8+
from .tools import ToolRegistry
9+
10+
if TYPE_CHECKING: # pragma: no cover - type hints only
11+
from playwright.async_api import Page
12+
from .tracing import Tracer
13+
else: # pragma: no cover - avoid optional runtime imports
14+
Page = Any # type: ignore
15+
Tracer = Any # type: ignore
16+
17+
18+
class SentienceDebugger:
19+
"""
20+
Verifier-only sidecar wrapper around AgentRuntime.
21+
"""
22+
23+
def __init__(self, runtime: AgentRuntime) -> None:
24+
self.runtime = runtime
25+
self._step_open = False
26+
27+
@classmethod
28+
def attach(
29+
cls,
30+
page: Page,
31+
tracer: Tracer,
32+
snapshot_options: SnapshotOptions | None = None,
33+
sentience_api_key: str | None = None,
34+
tool_registry: ToolRegistry | None = None,
35+
) -> "SentienceDebugger":
36+
runtime = AgentRuntime.from_playwright_page(
37+
page=page,
38+
tracer=tracer,
39+
snapshot_options=snapshot_options,
40+
sentience_api_key=sentience_api_key,
41+
tool_registry=tool_registry,
42+
)
43+
return cls(runtime=runtime)
44+
45+
def begin_step(self, goal: str, step_index: int | None = None) -> str:
46+
self._step_open = True
47+
return self.runtime.begin_step(goal, step_index=step_index)
48+
49+
async def end_step(self, **kwargs: Any) -> dict[str, Any]:
50+
self._step_open = False
51+
return await self.runtime.emit_step_end(**kwargs)
52+
53+
@asynccontextmanager
54+
async def step(self, goal: str, step_index: int | None = None) -> AsyncIterator[None]:
55+
self.begin_step(goal, step_index=step_index)
56+
try:
57+
yield
58+
finally:
59+
await self.end_step()
60+
61+
async def snapshot(self, **kwargs: Any):
62+
return await self.runtime.snapshot(**kwargs)
63+
64+
def check(self, predicate, label: str, required: bool = False):
65+
if not self._step_open:
66+
self.begin_step(f"verify:{label}")
67+
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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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("sentience.debugger.AgentRuntime.from_playwright_page", return_value=runtime) as mock_factory:
28+
from sentience.debugger import SentienceDebugger
29+
30+
debugger = SentienceDebugger.attach(page=mock_page, tracer=tracer)
31+
32+
mock_factory.assert_called_once_with(
33+
page=mock_page,
34+
tracer=tracer,
35+
snapshot_options=None,
36+
sentience_api_key=None,
37+
tool_registry=None,
38+
)
39+
assert debugger.runtime is runtime
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_step_context_calls_begin_and_emit() -> None:
44+
runtime = MockRuntime()
45+
46+
from sentience.debugger import SentienceDebugger
47+
48+
debugger = SentienceDebugger(runtime=runtime)
49+
50+
async with debugger.step("verify-cart"):
51+
pass
52+
53+
runtime.begin_step.assert_called_once_with("verify-cart", step_index=None)
54+
runtime.emit_step_end.assert_awaited_once()
55+
56+
57+
def test_check_auto_opens_step_when_missing() -> None:
58+
runtime = MockRuntime()
59+
60+
from sentience.debugger import SentienceDebugger
61+
62+
debugger = SentienceDebugger(runtime=runtime)
63+
predicate = MagicMock()
64+
65+
handle = debugger.check(predicate=predicate, label="has_cart", required=True)
66+
67+
runtime.begin_step.assert_called_once_with("verify:has_cart", step_index=None)
68+
runtime.check.assert_called_once_with(predicate, "has_cart", required=True)
69+
assert handle == "check-handle"

0 commit comments

Comments
 (0)