Skip to content

Commit 9c9116f

Browse files
author
SentienceDEV
committed
Make AgentRuntime support any browsers that conform to protocol_v0
1 parent 3ac8624 commit 9c9116f

File tree

11 files changed

+796
-76
lines changed

11 files changed

+796
-76
lines changed

screenshot.png

-3.84 KB
Loading

sentience/agent_runtime.py

Lines changed: 140 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,78 @@
22
Agent runtime for verification loop support.
33
44
This module provides a thin runtime wrapper that combines:
5-
1. Browser session management
5+
1. Browser session management (via BrowserBackendV0 protocol)
66
2. Snapshot/query helpers
77
3. Tracer for event emission
88
4. Assertion/verification methods
99
1010
The AgentRuntime is designed to be used in agent verification loops where
1111
you need to repeatedly take snapshots, execute actions, and verify results.
1212
13-
Example usage:
14-
from sentience import AsyncSentienceBrowser
13+
Example usage with browser-use:
14+
from browser_use import BrowserSession, BrowserProfile
15+
from sentience import get_extension_dir
16+
from sentience.backends import BrowserUseAdapter
1517
from sentience.agent_runtime import AgentRuntime
1618
from sentience.verification import url_matches, exists
1719
from sentience.tracing import Tracer, JsonlTraceSink
1820
21+
# Setup browser-use with Sentience extension
22+
profile = BrowserProfile(args=[f"--load-extension={get_extension_dir()}"])
23+
session = BrowserSession(browser_profile=profile)
24+
await session.start()
25+
26+
# Create adapter and backend
27+
adapter = BrowserUseAdapter(session)
28+
backend = await adapter.create_backend()
29+
30+
# Navigate using browser-use
31+
page = await session.get_current_page()
32+
await page.goto("https://example.com")
33+
34+
# Create runtime with backend
35+
sink = JsonlTraceSink("trace.jsonl")
36+
tracer = Tracer(run_id="test-run", sink=sink)
37+
runtime = AgentRuntime(backend=backend, tracer=tracer)
38+
39+
# Take snapshot and run assertions
40+
await runtime.snapshot()
41+
runtime.assert_(url_matches(r"example\\.com"), label="on_homepage")
42+
runtime.assert_(exists("role=button"), label="has_buttons")
43+
44+
# Check if task is done
45+
if runtime.assert_done(exists("text~'Success'"), label="task_complete"):
46+
print("Task completed!")
47+
48+
Example usage with AsyncSentienceBrowser (backward compatible):
49+
from sentience import AsyncSentienceBrowser
50+
from sentience.agent_runtime import AgentRuntime
51+
1952
async with AsyncSentienceBrowser() as browser:
2053
page = await browser.new_page()
2154
await page.goto("https://example.com")
2255
23-
sink = JsonlTraceSink("trace.jsonl")
24-
tracer = Tracer(run_id="test-run", sink=sink)
25-
26-
runtime = AgentRuntime(browser=browser, page=page, tracer=tracer)
27-
28-
# Take snapshot and run assertions
56+
runtime = await AgentRuntime.from_sentience_browser(
57+
browser=browser,
58+
page=page,
59+
tracer=tracer,
60+
)
2961
await runtime.snapshot()
30-
runtime.assert_(url_matches(r"example\\.com"), label="on_homepage")
31-
runtime.assert_(exists("role=button"), label="has_buttons")
32-
33-
# Check if task is done
34-
if runtime.assert_done(exists("text~'Success'"), label="task_complete"):
35-
print("Task completed!")
3662
"""
3763

3864
from __future__ import annotations
3965

4066
import uuid
4167
from typing import TYPE_CHECKING, Any
4268

43-
from .verification import AssertContext, AssertOutcome, Predicate
69+
from .models import Snapshot, SnapshotOptions
70+
from .verification import AssertContext, Predicate
4471

4572
if TYPE_CHECKING:
4673
from playwright.async_api import Page
4774

75+
from .backends.protocol_v0 import BrowserBackendV0
4876
from .browser import AsyncSentienceBrowser
49-
from .models import Snapshot
5077
from .tracing import Tracer
5178

5279

@@ -63,8 +90,7 @@ class AgentRuntime:
6390
to the tracer for Studio timeline display.
6491
6592
Attributes:
66-
browser: AsyncSentienceBrowser instance
67-
page: Playwright Page instance
93+
backend: BrowserBackendV0 instance for browser operations
6894
tracer: Tracer for event emission
6995
step_id: Current step identifier
7096
step_index: Current step index (0-based)
@@ -73,36 +99,90 @@ class AgentRuntime:
7399

74100
def __init__(
75101
self,
76-
browser: AsyncSentienceBrowser,
77-
page: Page,
102+
backend: BrowserBackendV0,
78103
tracer: Tracer,
104+
snapshot_options: SnapshotOptions | None = None,
105+
sentience_api_key: str | None = None,
79106
):
80107
"""
81-
Initialize agent runtime.
108+
Initialize agent runtime with any BrowserBackendV0-compatible browser.
82109
83110
Args:
84-
browser: AsyncSentienceBrowser instance for taking snapshots
85-
page: Playwright Page for browser interaction
111+
backend: Any browser implementing BrowserBackendV0 protocol.
112+
Examples:
113+
- CDPBackendV0 (for browser-use via BrowserUseAdapter)
114+
- PlaywrightBackend (future, for direct Playwright)
86115
tracer: Tracer for emitting verification events
116+
snapshot_options: Default options for snapshots
117+
sentience_api_key: API key for Pro/Enterprise tier (enables Gateway refinement)
87118
"""
88-
self.browser = browser
89-
self.page = page
119+
self.backend = backend
90120
self.tracer = tracer
91121

122+
# Build default snapshot options with API key if provided
123+
default_opts = snapshot_options or SnapshotOptions()
124+
if sentience_api_key:
125+
default_opts.sentience_api_key = sentience_api_key
126+
if default_opts.use_api is None:
127+
default_opts.use_api = True
128+
self._snapshot_options = default_opts
129+
92130
# Step tracking
93131
self.step_id: str | None = None
94132
self.step_index: int = 0
95133

96134
# Snapshot state
97135
self.last_snapshot: Snapshot | None = None
98136

137+
# Cached URL (updated on snapshot or explicit get_url call)
138+
self._cached_url: str | None = None
139+
99140
# Assertions accumulated during current step
100141
self._assertions_this_step: list[dict[str, Any]] = []
101142

102143
# Task completion tracking
103144
self._task_done: bool = False
104145
self._task_done_label: str | None = None
105146

147+
@classmethod
148+
async def from_sentience_browser(
149+
cls,
150+
browser: AsyncSentienceBrowser,
151+
page: Page,
152+
tracer: Tracer,
153+
snapshot_options: SnapshotOptions | None = None,
154+
sentience_api_key: str | None = None,
155+
) -> AgentRuntime:
156+
"""
157+
Create AgentRuntime from AsyncSentienceBrowser (backward compatibility).
158+
159+
This factory method wraps an AsyncSentienceBrowser + Page combination
160+
into the new BrowserBackendV0-based AgentRuntime.
161+
162+
Args:
163+
browser: AsyncSentienceBrowser instance
164+
page: Playwright Page for browser interaction
165+
tracer: Tracer for emitting verification events
166+
snapshot_options: Default options for snapshots
167+
sentience_api_key: API key for Pro/Enterprise tier
168+
169+
Returns:
170+
AgentRuntime instance
171+
"""
172+
from .backends.playwright_backend import PlaywrightBackend
173+
174+
backend = PlaywrightBackend(page)
175+
runtime = cls(
176+
backend=backend,
177+
tracer=tracer,
178+
snapshot_options=snapshot_options,
179+
sentience_api_key=sentience_api_key,
180+
)
181+
# Store browser reference for snapshot() to use
182+
runtime._legacy_browser = browser
183+
runtime._legacy_page = page
184+
return runtime
185+
106186
def _ctx(self) -> AssertContext:
107187
"""
108188
Build assertion context from current state.
@@ -113,28 +193,57 @@ def _ctx(self) -> AssertContext:
113193
url = None
114194
if self.last_snapshot is not None:
115195
url = self.last_snapshot.url
116-
elif self.page:
117-
url = self.page.url
196+
elif self._cached_url:
197+
url = self._cached_url
118198

119199
return AssertContext(
120200
snapshot=self.last_snapshot,
121201
url=url,
122202
step_id=self.step_id,
123203
)
124204

125-
async def snapshot(self, **kwargs) -> Snapshot:
205+
async def get_url(self) -> str:
206+
"""
207+
Get current page URL.
208+
209+
Returns:
210+
Current page URL
211+
"""
212+
url = await self.backend.get_url()
213+
self._cached_url = url
214+
return url
215+
216+
async def snapshot(self, **kwargs: Any) -> Snapshot:
126217
"""
127218
Take a snapshot of the current page state.
128219
129220
This updates last_snapshot which is used as context for assertions.
130221
131222
Args:
132-
**kwargs: Passed through to browser.snapshot()
223+
**kwargs: Override default snapshot options for this call.
224+
Common options:
225+
- limit: Maximum elements to return
226+
- goal: Task goal for ordinal support
227+
- screenshot: Include screenshot
228+
- show_overlay: Show visual overlay
133229
134230
Returns:
135231
Snapshot of current page state
136232
"""
137-
self.last_snapshot = await self.browser.snapshot(self.page, **kwargs)
233+
# Check if using legacy browser (backward compat)
234+
if hasattr(self, "_legacy_browser") and hasattr(self, "_legacy_page"):
235+
self.last_snapshot = await self._legacy_browser.snapshot(self._legacy_page, **kwargs)
236+
return self.last_snapshot
237+
238+
# Use backend-agnostic snapshot
239+
from .backends.snapshot import snapshot as backend_snapshot
240+
241+
# Merge default options with call-specific kwargs
242+
options_dict = self._snapshot_options.model_dump(exclude_none=True)
243+
options_dict.update(kwargs)
244+
options = SnapshotOptions(**options_dict)
245+
246+
self.last_snapshot = await backend_snapshot(self.backend, options=options)
138247
return self.last_snapshot
139248

140249
def begin_step(self, goal: str, step_index: int | None = None) -> str:

sentience/backends/cdp_backend.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,8 @@ async def wait_ready_state(
386386

387387
# Poll every 100ms
388388
await asyncio.sleep(0.1)
389+
390+
async def get_url(self) -> str:
391+
"""Get current page URL."""
392+
result = await self.eval("window.location.href")
393+
return result if result else ""

sentience/backends/playwright_backend.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ async def wait_ready_state(
185185

186186
await asyncio.sleep(0.1)
187187

188+
async def get_url(self) -> str:
189+
"""Get current page URL."""
190+
return self._page.url
191+
188192

189193
# Verify protocol compliance at import time
190194
assert isinstance(PlaywrightBackend.__new__(PlaywrightBackend), BrowserBackendV0)

sentience/backends/protocol_v0.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,12 @@ async def wait_ready_state(
205205
TimeoutError: If state not reached within timeout
206206
"""
207207
...
208+
209+
async def get_url(self) -> str:
210+
"""
211+
Get current page URL.
212+
213+
Returns:
214+
Current page URL (window.location.href)
215+
"""
216+
...

sentience/extension/background.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ async function handleSnapshotProcessing(rawData, options = {}) {
2828
const startTime = performance.now();
2929
try {
3030
if (!Array.isArray(rawData)) throw new Error("rawData must be an array");
31-
if (rawData.length > 1e4 && (rawData = rawData.slice(0, 1e4)), await initWASM(),
31+
if (rawData.length > 1e4 && (rawData = rawData.slice(0, 1e4)), await initWASM(),
3232
!wasmReady) throw new Error("WASM module not initialized");
3333
let analyzedElements, prunedRawData;
3434
try {
3535
const wasmPromise = new Promise((resolve, reject) => {
3636
try {
3737
let result;
38-
result = options.limit || options.filter ? analyze_page_with_options(rawData, options) : analyze_page(rawData),
38+
result = options.limit || options.filter ? analyze_page_with_options(rawData, options) : analyze_page(rawData),
3939
resolve(result);
4040
} catch (e) {
4141
reject(e);
@@ -101,4 +101,4 @@ initWASM().catch(err => {}), chrome.runtime.onMessage.addListener((request, send
101101
event.preventDefault();
102102
}), self.addEventListener("unhandledrejection", event => {
103103
event.preventDefault();
104-
});
104+
});

sentience/extension/content.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
if (!elements || !Array.isArray(elements)) return;
8383
removeOverlay();
8484
const host = document.createElement("div");
85-
host.id = OVERLAY_HOST_ID, host.style.cssText = "\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n pointer-events: none !important;\n z-index: 2147483647 !important;\n margin: 0 !important;\n padding: 0 !important;\n ",
85+
host.id = OVERLAY_HOST_ID, host.style.cssText = "\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n pointer-events: none !important;\n z-index: 2147483647 !important;\n margin: 0 !important;\n padding: 0 !important;\n ",
8686
document.body.appendChild(host);
8787
const shadow = host.attachShadow({
8888
mode: "closed"
@@ -94,15 +94,15 @@
9494
let color;
9595
color = isTarget ? "#FF0000" : isPrimary ? "#0066FF" : "#00FF00";
9696
const importanceRatio = maxImportance > 0 ? importance / maxImportance : .5, borderOpacity = isTarget ? 1 : isPrimary ? .9 : Math.max(.4, .5 + .5 * importanceRatio), fillOpacity = .2 * borderOpacity, borderWidth = isTarget ? 2 : isPrimary ? 1.5 : Math.max(.5, Math.round(2 * importanceRatio)), hexOpacity = Math.round(255 * fillOpacity).toString(16).padStart(2, "0"), box = document.createElement("div");
97-
if (box.style.cssText = `\n position: absolute;\n left: ${bbox.x}px;\n top: ${bbox.y}px;\n width: ${bbox.width}px;\n height: ${bbox.height}px;\n border: ${borderWidth}px solid ${color};\n background-color: ${color}${hexOpacity};\n box-sizing: border-box;\n opacity: ${borderOpacity};\n pointer-events: none;\n `,
97+
if (box.style.cssText = `\n position: absolute;\n left: ${bbox.x}px;\n top: ${bbox.y}px;\n width: ${bbox.width}px;\n height: ${bbox.height}px;\n border: ${borderWidth}px solid ${color};\n background-color: ${color}${hexOpacity};\n box-sizing: border-box;\n opacity: ${borderOpacity};\n pointer-events: none;\n `,
9898
importance > 0 || isPrimary) {
9999
const badge = document.createElement("span");
100-
badge.textContent = isPrimary ? `⭐${importance}` : `${importance}`, badge.style.cssText = `\n position: absolute;\n top: -18px;\n left: 0;\n background: ${color};\n color: white;\n font-size: 11px;\n font-weight: bold;\n padding: 2px 6px;\n font-family: Arial, sans-serif;\n border-radius: 3px;\n opacity: 0.95;\n white-space: nowrap;\n pointer-events: none;\n `,
100+
badge.textContent = isPrimary ? `⭐${importance}` : `${importance}`, badge.style.cssText = `\n position: absolute;\n top: -18px;\n left: 0;\n background: ${color};\n color: white;\n font-size: 11px;\n font-weight: bold;\n padding: 2px 6px;\n font-family: Arial, sans-serif;\n border-radius: 3px;\n opacity: 0.95;\n white-space: nowrap;\n pointer-events: none;\n `,
101101
box.appendChild(badge);
102102
}
103103
if (isTarget) {
104104
const targetIndicator = document.createElement("span");
105-
targetIndicator.textContent = "🎯", targetIndicator.style.cssText = "\n position: absolute;\n top: -18px;\n right: 0;\n font-size: 16px;\n pointer-events: none;\n ",
105+
targetIndicator.textContent = "🎯", targetIndicator.style.cssText = "\n position: absolute;\n top: -18px;\n right: 0;\n font-size: 16px;\n pointer-events: none;\n ",
106106
box.appendChild(targetIndicator);
107107
}
108108
shadow.appendChild(box);
@@ -120,7 +120,7 @@
120120
let overlayTimeout = null;
121121
function removeOverlay() {
122122
const existing = document.getElementById(OVERLAY_HOST_ID);
123-
existing && existing.remove(), overlayTimeout && (clearTimeout(overlayTimeout),
123+
existing && existing.remove(), overlayTimeout && (clearTimeout(overlayTimeout),
124124
overlayTimeout = null);
125125
}
126-
}();
126+
}();

0 commit comments

Comments
 (0)