From 73e22245f2f0703f30ca6621f2a5443ba24a72cc Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Thu, 29 Jan 2026 17:46:06 -0800 Subject: [PATCH 1/2] Sentience Debugger for any agent --- sentience/__init__.py | 2 ++ sentience/agent_runtime.py | 53 ++++++++++++++++++++++++++++ sentience/debugger.py | 67 +++++++++++++++++++++++++++++++++++ tests/test_agent_runtime.py | 35 +++++++++++++++++++ tests/test_debugger.py | 69 +++++++++++++++++++++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 sentience/debugger.py create mode 100644 tests/test_debugger.py diff --git a/sentience/__init__.py b/sentience/__init__.py index 278f506..a3c73cc 100644 --- a/sentience/__init__.py +++ b/sentience/__init__.py @@ -32,6 +32,7 @@ from .agent import SentienceAgent, SentienceAgentAsync from .agent_config import AgentConfig from .agent_runtime import AgentRuntime, AssertionHandle +from .debugger import SentienceDebugger # Backend-agnostic actions (aliased to avoid conflict with existing actions) # Browser backends (for browser-use integration) @@ -286,6 +287,7 @@ "AgentAction", # Verification (agent assertion loop) "AgentRuntime", + "SentienceDebugger", "AssertContext", "AssertOutcome", "Predicate", diff --git a/sentience/agent_runtime.py b/sentience/agent_runtime.py index c8b3131..72922b8 100644 --- a/sentience/agent_runtime.py +++ b/sentience/agent_runtime.py @@ -185,6 +185,59 @@ def __init__( self._captcha_options: CaptchaOptions | None = None self._captcha_retry_count: int = 0 + @classmethod + def from_playwright_page( + cls, + page: Page, + tracer: Tracer, + snapshot_options: SnapshotOptions | None = None, + sentience_api_key: str | None = None, + tool_registry: ToolRegistry | None = None, + ) -> AgentRuntime: + """ + Create AgentRuntime from a raw Playwright Page (sidecar mode). + + Args: + page: Playwright Page for browser interaction + tracer: Tracer for emitting verification events + snapshot_options: Default options for snapshots + sentience_api_key: API key for Pro/Enterprise tier + tool_registry: Optional ToolRegistry for LLM-callable tools + + Returns: + AgentRuntime instance + """ + from .backends.playwright_backend import PlaywrightBackend + + backend = PlaywrightBackend(page) + return cls( + backend=backend, + tracer=tracer, + snapshot_options=snapshot_options, + sentience_api_key=sentience_api_key, + tool_registry=tool_registry, + ) + + @classmethod + def attach( + cls, + page: Page, + tracer: Tracer, + snapshot_options: SnapshotOptions | None = None, + sentience_api_key: str | None = None, + tool_registry: ToolRegistry | None = None, + ) -> AgentRuntime: + """ + Sidecar alias for from_playwright_page(). + """ + return cls.from_playwright_page( + page=page, + tracer=tracer, + snapshot_options=snapshot_options, + sentience_api_key=sentience_api_key, + tool_registry=tool_registry, + ) + @classmethod async def from_sentience_browser( cls, diff --git a/sentience/debugger.py b/sentience/debugger.py new file mode 100644 index 0000000..3e2e86e --- /dev/null +++ b/sentience/debugger.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, AsyncIterator + +from .agent_runtime import AgentRuntime +from .models import SnapshotOptions +from .tools import ToolRegistry + +if TYPE_CHECKING: # pragma: no cover - type hints only + from playwright.async_api import Page + from .tracing import Tracer +else: # pragma: no cover - avoid optional runtime imports + Page = Any # type: ignore + Tracer = Any # type: ignore + + +class SentienceDebugger: + """ + Verifier-only sidecar wrapper around AgentRuntime. + """ + + def __init__(self, runtime: AgentRuntime) -> None: + self.runtime = runtime + self._step_open = False + + @classmethod + def attach( + cls, + page: Page, + tracer: Tracer, + snapshot_options: SnapshotOptions | None = None, + sentience_api_key: str | None = None, + tool_registry: ToolRegistry | None = None, + ) -> "SentienceDebugger": + runtime = AgentRuntime.from_playwright_page( + page=page, + tracer=tracer, + snapshot_options=snapshot_options, + sentience_api_key=sentience_api_key, + tool_registry=tool_registry, + ) + return cls(runtime=runtime) + + def begin_step(self, goal: str, step_index: int | None = None) -> str: + self._step_open = True + return self.runtime.begin_step(goal, step_index=step_index) + + async def end_step(self, **kwargs: Any) -> dict[str, Any]: + self._step_open = False + return await self.runtime.emit_step_end(**kwargs) + + @asynccontextmanager + async def step(self, goal: str, step_index: int | None = None) -> AsyncIterator[None]: + self.begin_step(goal, step_index=step_index) + try: + yield + finally: + await self.end_step() + + async def snapshot(self, **kwargs: Any): + return await self.runtime.snapshot(**kwargs) + + def check(self, predicate, label: str, required: bool = False): + if not self._step_open: + self.begin_step(f"verify:{label}") + return self.runtime.check(predicate, label, required=required) diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index 130ce5c..291ec7e 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -695,6 +695,41 @@ async def test_from_sentience_browser_with_api_key(self) -> None: assert runtime._snapshot_options.use_api is True +class TestAgentRuntimeFromPlaywrightPage: + """Tests for from_playwright_page factory method.""" + + def test_from_playwright_page_creates_runtime(self) -> None: + """Test from_playwright_page creates runtime with PlaywrightBackend.""" + mock_page = MagicMock() + tracer = MockTracer() + + with patch("sentience.backends.playwright_backend.PlaywrightBackend") as MockPWBackend: + mock_backend_instance = MagicMock() + MockPWBackend.return_value = mock_backend_instance + + runtime = AgentRuntime.from_playwright_page(page=mock_page, tracer=tracer) + + assert runtime.backend is mock_backend_instance + assert not hasattr(runtime, "_legacy_browser") + assert not hasattr(runtime, "_legacy_page") + MockPWBackend.assert_called_once_with(mock_page) + + def test_from_playwright_page_with_api_key(self) -> None: + """Test from_playwright_page passes API key.""" + mock_page = MagicMock() + tracer = MockTracer() + + with patch("sentience.backends.playwright_backend.PlaywrightBackend"): + runtime = AgentRuntime.from_playwright_page( + page=mock_page, + tracer=tracer, + sentience_api_key="sk_test", + ) + + assert runtime._snapshot_options.sentience_api_key == "sk_test" + assert runtime._snapshot_options.use_api is True + + class TestAgentRuntimeSnapshot: """Tests for snapshot method.""" diff --git a/tests/test_debugger.py b/tests/test_debugger.py new file mode 100644 index 0000000..dfa9738 --- /dev/null +++ b/tests/test_debugger.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class MockRuntime: + def __init__(self) -> None: + self.begin_step = MagicMock(return_value="step-1") + self.emit_step_end = AsyncMock(return_value={"ok": True}) + self.check = MagicMock(return_value="check-handle") + + +class MockTracer: + def emit(self, event_type: str, data: dict, step_id: str | None = None) -> None: + pass + + +@pytest.mark.asyncio +async def test_attach_uses_runtime_factory() -> None: + mock_page = MagicMock() + tracer = MockTracer() + runtime = MockRuntime() + + with patch("sentience.debugger.AgentRuntime.from_playwright_page", return_value=runtime) as mock_factory: + from sentience.debugger import SentienceDebugger + + debugger = SentienceDebugger.attach(page=mock_page, tracer=tracer) + + mock_factory.assert_called_once_with( + page=mock_page, + tracer=tracer, + snapshot_options=None, + sentience_api_key=None, + tool_registry=None, + ) + assert debugger.runtime is runtime + + +@pytest.mark.asyncio +async def test_step_context_calls_begin_and_emit() -> None: + runtime = MockRuntime() + + from sentience.debugger import SentienceDebugger + + debugger = SentienceDebugger(runtime=runtime) + + async with debugger.step("verify-cart"): + pass + + runtime.begin_step.assert_called_once_with("verify-cart", step_index=None) + runtime.emit_step_end.assert_awaited_once() + + +def test_check_auto_opens_step_when_missing() -> None: + runtime = MockRuntime() + + from sentience.debugger import SentienceDebugger + + debugger = SentienceDebugger(runtime=runtime) + predicate = MagicMock() + + handle = debugger.check(predicate=predicate, label="has_cart", required=True) + + runtime.begin_step.assert_called_once_with("verify:has_cart", step_index=None) + runtime.check.assert_called_once_with(predicate, "has_cart", required=True) + assert handle == "check-handle" From 66530dde5b7f2368a800fcd7d26b471aaeb8e55f Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Thu, 29 Jan 2026 18:03:10 -0800 Subject: [PATCH 2/2] formatted --- sentience/__init__.py | 2 +- sentience/debugger.py | 6 ++++-- tests/test_debugger.py | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/sentience/__init__.py b/sentience/__init__.py index a3c73cc..358ea85 100644 --- a/sentience/__init__.py +++ b/sentience/__init__.py @@ -32,7 +32,6 @@ from .agent import SentienceAgent, SentienceAgentAsync from .agent_config import AgentConfig from .agent_runtime import AgentRuntime, AssertionHandle -from .debugger import SentienceDebugger # Backend-agnostic actions (aliased to avoid conflict with existing actions) # Browser backends (for browser-use integration) @@ -64,6 +63,7 @@ from .cloud_tracing import CloudTraceSink, SentienceLogger from .conversational_agent import ConversationalAgent from .cursor_policy import CursorPolicy +from .debugger import SentienceDebugger from .expect import expect from .generator import ScriptGenerator, generate from .inspector import Inspector, inspect diff --git a/sentience/debugger.py b/sentience/debugger.py index 3e2e86e..a945d5a 100644 --- a/sentience/debugger.py +++ b/sentience/debugger.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any, AsyncIterator +from typing import TYPE_CHECKING, Any from .agent_runtime import AgentRuntime from .models import SnapshotOptions @@ -9,6 +10,7 @@ if TYPE_CHECKING: # pragma: no cover - type hints only from playwright.async_api import Page + from .tracing import Tracer else: # pragma: no cover - avoid optional runtime imports Page = Any # type: ignore @@ -32,7 +34,7 @@ def attach( snapshot_options: SnapshotOptions | None = None, sentience_api_key: str | None = None, tool_registry: ToolRegistry | None = None, - ) -> "SentienceDebugger": + ) -> SentienceDebugger: runtime = AgentRuntime.from_playwright_page( page=page, tracer=tracer, diff --git a/tests/test_debugger.py b/tests/test_debugger.py index dfa9738..8279be1 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -24,7 +24,9 @@ async def test_attach_uses_runtime_factory() -> None: tracer = MockTracer() runtime = MockRuntime() - with patch("sentience.debugger.AgentRuntime.from_playwright_page", return_value=runtime) as mock_factory: + with patch( + "sentience.debugger.AgentRuntime.from_playwright_page", return_value=runtime + ) as mock_factory: from sentience.debugger import SentienceDebugger debugger = SentienceDebugger.attach(page=mock_page, tracer=tracer)