Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sentience/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,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
Expand Down Expand Up @@ -286,6 +287,7 @@
"AgentAction",
# Verification (agent assertion loop)
"AgentRuntime",
"SentienceDebugger",
"AssertContext",
"AssertOutcome",
"Predicate",
Expand Down
53 changes: 53 additions & 0 deletions sentience/agent_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions sentience/debugger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any

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)
35 changes: 35 additions & 0 deletions tests/test_agent_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
71 changes: 71 additions & 0 deletions tests/test_debugger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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"
Loading