sentienceapi 0.90.16__py3-none-any.whl → 0.98.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sentienceapi might be problematic. Click here for more details.
- sentience/__init__.py +120 -6
- sentience/_extension_loader.py +156 -1
- sentience/action_executor.py +217 -0
- sentience/actions.py +758 -30
- sentience/agent.py +806 -293
- sentience/agent_config.py +3 -0
- sentience/agent_runtime.py +840 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +89 -1141
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +372 -0
- sentience/backends/browser_use_adapter.py +241 -0
- sentience/backends/cdp_backend.py +393 -0
- sentience/backends/exceptions.py +211 -0
- sentience/backends/playwright_backend.py +194 -0
- sentience/backends/protocol.py +216 -0
- sentience/backends/sentience_context.py +469 -0
- sentience/backends/snapshot.py +483 -0
- sentience/base_agent.py +95 -0
- sentience/browser.py +678 -39
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cloud_tracing.py +507 -42
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +77 -43
- sentience/cursor_policy.py +142 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +98 -2
- sentience/extension/background.js +56 -185
- sentience/extension/content.js +150 -287
- sentience/extension/injected_api.js +1088 -1368
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.d.ts +22 -22
- sentience/extension/pkg/sentience_core.js +275 -433
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +47 -47
- sentience/failure_artifacts.py +241 -0
- sentience/formatting.py +9 -53
- sentience/inspector.py +183 -1
- sentience/integrations/__init__.py +6 -0
- sentience/integrations/langchain/__init__.py +12 -0
- sentience/integrations/langchain/context.py +18 -0
- sentience/integrations/langchain/core.py +326 -0
- sentience/integrations/langchain/tools.py +180 -0
- sentience/integrations/models.py +46 -0
- sentience/integrations/pydanticai/__init__.py +15 -0
- sentience/integrations/pydanticai/deps.py +20 -0
- sentience/integrations/pydanticai/toolset.py +468 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +765 -66
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +595 -3
- sentience/ordinal.py +280 -0
- sentience/overlay.py +109 -2
- sentience/protocols.py +228 -0
- sentience/query.py +67 -5
- sentience/read.py +95 -3
- sentience/recorder.py +223 -3
- sentience/schemas/trace_v1.json +128 -9
- sentience/screenshot.py +48 -2
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +599 -55
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +120 -5
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/index_schema.py +95 -7
- sentience/trace_indexing/indexer.py +105 -48
- sentience/tracer_factory.py +120 -9
- sentience/tracing.py +172 -8
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/{utils.py → utils/element.py} +3 -42
- sentience/utils/formatting.py +59 -0
- sentience/verification.py +618 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +68 -2
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +199 -40
- sentienceapi-0.98.0.dist-info/RECORD +92 -0
- sentience/extension/test-content.js +0 -4
- sentienceapi-0.90.16.dist-info/RECORD +0 -50
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for Sentience backends.
|
|
3
|
+
|
|
4
|
+
These exceptions provide clear, actionable error messages when things go wrong
|
|
5
|
+
during browser-use integration or backend operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SentienceBackendError(Exception):
|
|
13
|
+
"""Base exception for all Sentience backend errors."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ExtensionDiagnostics:
|
|
20
|
+
"""Diagnostics collected when extension loading fails."""
|
|
21
|
+
|
|
22
|
+
sentience_defined: bool = False
|
|
23
|
+
sentience_snapshot: bool = False
|
|
24
|
+
url: str = ""
|
|
25
|
+
error: str | None = None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: dict[str, Any]) -> "ExtensionDiagnostics":
|
|
29
|
+
"""Create from diagnostic dict returned by browser eval."""
|
|
30
|
+
return cls(
|
|
31
|
+
sentience_defined=data.get("sentience_defined", False),
|
|
32
|
+
sentience_snapshot=data.get("sentience_snapshot", False),
|
|
33
|
+
url=data.get("url", ""),
|
|
34
|
+
error=data.get("error"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
"""Convert to dict for serialization."""
|
|
39
|
+
return {
|
|
40
|
+
"sentience_defined": self.sentience_defined,
|
|
41
|
+
"sentience_snapshot": self.sentience_snapshot,
|
|
42
|
+
"url": self.url,
|
|
43
|
+
"error": self.error,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExtensionNotLoadedError(SentienceBackendError):
|
|
48
|
+
"""
|
|
49
|
+
Raised when the Sentience extension is not loaded in the browser.
|
|
50
|
+
|
|
51
|
+
This typically means:
|
|
52
|
+
1. Browser was launched without --load-extension flag
|
|
53
|
+
2. Extension path is incorrect
|
|
54
|
+
3. Extension failed to initialize
|
|
55
|
+
|
|
56
|
+
Example fix for browser-use:
|
|
57
|
+
from sentience import get_extension_dir
|
|
58
|
+
from browser_use import BrowserSession, BrowserProfile
|
|
59
|
+
|
|
60
|
+
profile = BrowserProfile(
|
|
61
|
+
args=[f"--load-extension={get_extension_dir()}"],
|
|
62
|
+
)
|
|
63
|
+
session = BrowserSession(browser_profile=profile)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str,
|
|
69
|
+
timeout_ms: int | None = None,
|
|
70
|
+
diagnostics: ExtensionDiagnostics | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.timeout_ms = timeout_ms
|
|
73
|
+
self.diagnostics = diagnostics
|
|
74
|
+
super().__init__(message)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_timeout(
|
|
78
|
+
cls,
|
|
79
|
+
timeout_ms: int,
|
|
80
|
+
diagnostics: ExtensionDiagnostics | None = None,
|
|
81
|
+
) -> "ExtensionNotLoadedError":
|
|
82
|
+
"""Create error from timeout during extension wait."""
|
|
83
|
+
diag_info = ""
|
|
84
|
+
if diagnostics:
|
|
85
|
+
if diagnostics.error:
|
|
86
|
+
diag_info = f"\n Error: {diagnostics.error}"
|
|
87
|
+
else:
|
|
88
|
+
diag_info = (
|
|
89
|
+
f"\n window.sentience defined: {diagnostics.sentience_defined}"
|
|
90
|
+
f"\n window.sentience.snapshot available: {diagnostics.sentience_snapshot}"
|
|
91
|
+
f"\n Page URL: {diagnostics.url}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
message = (
|
|
95
|
+
f"Sentience extension not loaded after {timeout_ms}ms.{diag_info}\n\n"
|
|
96
|
+
"To fix this, ensure the extension is loaded when launching the browser:\n\n"
|
|
97
|
+
" from sentience import get_extension_dir\n"
|
|
98
|
+
" from browser_use import BrowserSession, BrowserProfile\n\n"
|
|
99
|
+
" profile = BrowserProfile(\n"
|
|
100
|
+
f' args=[f"--load-extension={{get_extension_dir()}}"],\n'
|
|
101
|
+
" )\n"
|
|
102
|
+
" session = BrowserSession(browser_profile=profile)\n"
|
|
103
|
+
)
|
|
104
|
+
return cls(message, timeout_ms=timeout_ms, diagnostics=diagnostics)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ExtensionInjectionError(SentienceBackendError):
|
|
108
|
+
"""
|
|
109
|
+
Raised when window.sentience API is not available on the page.
|
|
110
|
+
|
|
111
|
+
This can happen when:
|
|
112
|
+
1. Page loaded before extension could inject
|
|
113
|
+
2. Page has Content Security Policy blocking extension
|
|
114
|
+
3. Extension crashed or was disabled
|
|
115
|
+
|
|
116
|
+
Call snapshot() with a longer timeout or wait for page load.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
message: str,
|
|
122
|
+
url: str | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
self.url = url
|
|
125
|
+
super().__init__(message)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_page(cls, url: str) -> "ExtensionInjectionError":
|
|
129
|
+
"""Create error for a specific page."""
|
|
130
|
+
message = (
|
|
131
|
+
f"window.sentience API not available on page: {url}\n\n"
|
|
132
|
+
"Possible causes:\n"
|
|
133
|
+
" 1. Page loaded before extension could inject (try increasing timeout)\n"
|
|
134
|
+
" 2. Page has Content Security Policy blocking the extension\n"
|
|
135
|
+
" 3. Extension was disabled or crashed\n\n"
|
|
136
|
+
"Try:\n"
|
|
137
|
+
" snap = await snapshot(backend, options=SnapshotOptions(timeout_ms=10000))"
|
|
138
|
+
)
|
|
139
|
+
return cls(message, url=url)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BackendEvalError(SentienceBackendError):
|
|
143
|
+
"""
|
|
144
|
+
Raised when JavaScript evaluation fails in the browser.
|
|
145
|
+
|
|
146
|
+
This wraps underlying CDP or Playwright errors with context.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
message: str,
|
|
152
|
+
expression: str | None = None,
|
|
153
|
+
original_error: Exception | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
self.expression = expression
|
|
156
|
+
self.original_error = original_error
|
|
157
|
+
super().__init__(message)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SnapshotError(SentienceBackendError):
|
|
161
|
+
"""
|
|
162
|
+
Raised when taking a snapshot fails.
|
|
163
|
+
|
|
164
|
+
This can happen when:
|
|
165
|
+
1. Extension returned null or invalid data
|
|
166
|
+
2. Page is in an invalid state
|
|
167
|
+
3. Extension threw an error
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
message: str,
|
|
173
|
+
url: str | None = None,
|
|
174
|
+
raw_result: Any = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.url = url
|
|
177
|
+
self.raw_result = raw_result
|
|
178
|
+
super().__init__(message)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_null_result(cls, url: str | None = None) -> "SnapshotError":
|
|
182
|
+
"""Create error for null snapshot result."""
|
|
183
|
+
message = (
|
|
184
|
+
"window.sentience.snapshot() returned null.\n\n"
|
|
185
|
+
"Possible causes:\n"
|
|
186
|
+
" 1. Extension is not properly initialized\n"
|
|
187
|
+
" 2. Page DOM is in an invalid state\n"
|
|
188
|
+
" 3. Extension encountered an internal error\n\n"
|
|
189
|
+
"Try refreshing the page and taking a new snapshot."
|
|
190
|
+
)
|
|
191
|
+
if url:
|
|
192
|
+
message = f"{message}\n Page URL: {url}"
|
|
193
|
+
return cls(message, url=url, raw_result=None)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ActionError(SentienceBackendError):
|
|
197
|
+
"""
|
|
198
|
+
Raised when a browser action (click, type, scroll) fails.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
action: str,
|
|
204
|
+
message: str,
|
|
205
|
+
coordinates: tuple[float, float] | None = None,
|
|
206
|
+
original_error: Exception | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
self.action = action
|
|
209
|
+
self.coordinates = coordinates
|
|
210
|
+
self.original_error = original_error
|
|
211
|
+
super().__init__(f"{action} failed: {message}")
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Playwright backend implementation for BrowserBackend protocol.
|
|
3
|
+
|
|
4
|
+
This wraps existing SentienceBrowser/AsyncSentienceBrowser to provide
|
|
5
|
+
a unified interface, enabling code that works with both browser-use
|
|
6
|
+
(CDPBackendV0) and native Playwright (PlaywrightBackend).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from sentience import SentienceBrowserAsync
|
|
10
|
+
from sentience.backends import PlaywrightBackend, snapshot_from_backend
|
|
11
|
+
|
|
12
|
+
browser = SentienceBrowserAsync()
|
|
13
|
+
await browser.start()
|
|
14
|
+
await browser.goto("https://example.com")
|
|
15
|
+
|
|
16
|
+
# Create backend from existing browser
|
|
17
|
+
backend = PlaywrightBackend(browser.page)
|
|
18
|
+
|
|
19
|
+
# Use backend-agnostic functions
|
|
20
|
+
snap = await snapshot_from_backend(backend)
|
|
21
|
+
await click(backend, element.bbox)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import base64
|
|
26
|
+
import time
|
|
27
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
28
|
+
|
|
29
|
+
from .protocol import BrowserBackend, LayoutMetrics, ViewportInfo
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from playwright.async_api import Page as AsyncPage
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlaywrightBackend:
|
|
36
|
+
"""
|
|
37
|
+
Playwright-based implementation of BrowserBackend.
|
|
38
|
+
|
|
39
|
+
Wraps a Playwright async Page to provide the standard backend interface.
|
|
40
|
+
This enables using backend-agnostic actions with existing SentienceBrowser code.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, page: "AsyncPage") -> None:
|
|
44
|
+
"""
|
|
45
|
+
Initialize Playwright backend.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
page: Playwright async Page object
|
|
49
|
+
"""
|
|
50
|
+
self._page = page
|
|
51
|
+
self._cached_viewport: ViewportInfo | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def page(self) -> "AsyncPage":
|
|
55
|
+
"""Access the underlying Playwright page."""
|
|
56
|
+
return self._page
|
|
57
|
+
|
|
58
|
+
async def refresh_page_info(self) -> ViewportInfo:
|
|
59
|
+
"""Cache viewport + scroll offsets; cheap & safe to call often."""
|
|
60
|
+
result = await self._page.evaluate(
|
|
61
|
+
"""
|
|
62
|
+
(() => ({
|
|
63
|
+
width: window.innerWidth,
|
|
64
|
+
height: window.innerHeight,
|
|
65
|
+
scroll_x: window.scrollX,
|
|
66
|
+
scroll_y: window.scrollY,
|
|
67
|
+
content_width: document.documentElement.scrollWidth,
|
|
68
|
+
content_height: document.documentElement.scrollHeight
|
|
69
|
+
}))()
|
|
70
|
+
"""
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self._cached_viewport = ViewportInfo(
|
|
74
|
+
width=result.get("width", 0),
|
|
75
|
+
height=result.get("height", 0),
|
|
76
|
+
scroll_x=result.get("scroll_x", 0),
|
|
77
|
+
scroll_y=result.get("scroll_y", 0),
|
|
78
|
+
content_width=result.get("content_width"),
|
|
79
|
+
content_height=result.get("content_height"),
|
|
80
|
+
)
|
|
81
|
+
return self._cached_viewport
|
|
82
|
+
|
|
83
|
+
async def eval(self, expression: str) -> Any:
|
|
84
|
+
"""Evaluate JavaScript expression in page context."""
|
|
85
|
+
return await self._page.evaluate(expression)
|
|
86
|
+
|
|
87
|
+
async def call(
|
|
88
|
+
self,
|
|
89
|
+
function_declaration: str,
|
|
90
|
+
args: list[Any] | None = None,
|
|
91
|
+
) -> Any:
|
|
92
|
+
"""Call JavaScript function with arguments."""
|
|
93
|
+
if args:
|
|
94
|
+
return await self._page.evaluate(function_declaration, *args)
|
|
95
|
+
return await self._page.evaluate(f"({function_declaration})()")
|
|
96
|
+
|
|
97
|
+
async def get_layout_metrics(self) -> LayoutMetrics:
|
|
98
|
+
"""Get page layout metrics."""
|
|
99
|
+
# Playwright doesn't expose CDP directly in the same way,
|
|
100
|
+
# so we approximate using JavaScript
|
|
101
|
+
result = await self._page.evaluate(
|
|
102
|
+
"""
|
|
103
|
+
(() => ({
|
|
104
|
+
viewport_x: window.scrollX,
|
|
105
|
+
viewport_y: window.scrollY,
|
|
106
|
+
viewport_width: window.innerWidth,
|
|
107
|
+
viewport_height: window.innerHeight,
|
|
108
|
+
content_width: document.documentElement.scrollWidth,
|
|
109
|
+
content_height: document.documentElement.scrollHeight,
|
|
110
|
+
device_scale_factor: window.devicePixelRatio || 1
|
|
111
|
+
}))()
|
|
112
|
+
"""
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return LayoutMetrics(
|
|
116
|
+
viewport_x=result.get("viewport_x", 0),
|
|
117
|
+
viewport_y=result.get("viewport_y", 0),
|
|
118
|
+
viewport_width=result.get("viewport_width", 0),
|
|
119
|
+
viewport_height=result.get("viewport_height", 0),
|
|
120
|
+
content_width=result.get("content_width", 0),
|
|
121
|
+
content_height=result.get("content_height", 0),
|
|
122
|
+
device_scale_factor=result.get("device_scale_factor", 1.0),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def screenshot_png(self) -> bytes:
|
|
126
|
+
"""Capture viewport screenshot as PNG bytes."""
|
|
127
|
+
return await self._page.screenshot(type="png")
|
|
128
|
+
|
|
129
|
+
async def mouse_move(self, x: float, y: float) -> None:
|
|
130
|
+
"""Move mouse to viewport coordinates."""
|
|
131
|
+
await self._page.mouse.move(x, y)
|
|
132
|
+
|
|
133
|
+
async def mouse_click(
|
|
134
|
+
self,
|
|
135
|
+
x: float,
|
|
136
|
+
y: float,
|
|
137
|
+
button: Literal["left", "right", "middle"] = "left",
|
|
138
|
+
click_count: int = 1,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Click at viewport coordinates."""
|
|
141
|
+
await self._page.mouse.click(x, y, button=button, click_count=click_count)
|
|
142
|
+
|
|
143
|
+
async def wheel(
|
|
144
|
+
self,
|
|
145
|
+
delta_y: float,
|
|
146
|
+
x: float | None = None,
|
|
147
|
+
y: float | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Scroll using mouse wheel."""
|
|
150
|
+
# Get viewport center if coordinates not provided
|
|
151
|
+
if x is None or y is None:
|
|
152
|
+
if self._cached_viewport is None:
|
|
153
|
+
await self.refresh_page_info()
|
|
154
|
+
assert self._cached_viewport is not None
|
|
155
|
+
x = x if x is not None else self._cached_viewport.width / 2
|
|
156
|
+
y = y if y is not None else self._cached_viewport.height / 2
|
|
157
|
+
|
|
158
|
+
await self._page.mouse.wheel(0, delta_y)
|
|
159
|
+
|
|
160
|
+
async def type_text(self, text: str) -> None:
|
|
161
|
+
"""Type text using keyboard input."""
|
|
162
|
+
await self._page.keyboard.type(text)
|
|
163
|
+
|
|
164
|
+
async def wait_ready_state(
|
|
165
|
+
self,
|
|
166
|
+
state: Literal["interactive", "complete"] = "interactive",
|
|
167
|
+
timeout_ms: int = 15000,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Wait for document.readyState to reach target state."""
|
|
170
|
+
acceptable_states = {"complete"} if state == "complete" else {"interactive", "complete"}
|
|
171
|
+
|
|
172
|
+
start = time.monotonic()
|
|
173
|
+
timeout_sec = timeout_ms / 1000.0
|
|
174
|
+
|
|
175
|
+
while True:
|
|
176
|
+
elapsed = time.monotonic() - start
|
|
177
|
+
if elapsed >= timeout_sec:
|
|
178
|
+
raise TimeoutError(
|
|
179
|
+
f"Timed out waiting for document.readyState='{state}' " f"after {timeout_ms}ms"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
current_state = await self._page.evaluate("document.readyState")
|
|
183
|
+
if current_state in acceptable_states:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
await asyncio.sleep(0.1)
|
|
187
|
+
|
|
188
|
+
async def get_url(self) -> str:
|
|
189
|
+
"""Get current page URL."""
|
|
190
|
+
return self._page.url
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Verify protocol compliance at import time
|
|
194
|
+
assert isinstance(PlaywrightBackend.__new__(PlaywrightBackend), BrowserBackend)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
v0 BrowserBackend Protocol - Minimal interface for browser-use integration.
|
|
3
|
+
|
|
4
|
+
This protocol defines the minimal interface required to:
|
|
5
|
+
- Take Sentience snapshots (DOM/geometry via extension)
|
|
6
|
+
- Compute viewport-coord clicks
|
|
7
|
+
- Scroll + re-snapshot + click
|
|
8
|
+
- Stabilize after action
|
|
9
|
+
|
|
10
|
+
No navigation API required (browser-use already handles navigation).
|
|
11
|
+
|
|
12
|
+
Design principle: Keep it so small that nothing can break.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ViewportInfo(BaseModel):
|
|
21
|
+
"""Viewport and scroll position information."""
|
|
22
|
+
|
|
23
|
+
width: int
|
|
24
|
+
height: int
|
|
25
|
+
scroll_x: float = 0.0
|
|
26
|
+
scroll_y: float = 0.0
|
|
27
|
+
content_width: float | None = None
|
|
28
|
+
content_height: float | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LayoutMetrics(BaseModel):
|
|
32
|
+
"""Page layout metrics from CDP Page.getLayoutMetrics."""
|
|
33
|
+
|
|
34
|
+
# Viewport dimensions
|
|
35
|
+
viewport_x: float = 0.0
|
|
36
|
+
viewport_y: float = 0.0
|
|
37
|
+
viewport_width: float = 0.0
|
|
38
|
+
viewport_height: float = 0.0
|
|
39
|
+
|
|
40
|
+
# Content dimensions (scrollable area)
|
|
41
|
+
content_width: float = 0.0
|
|
42
|
+
content_height: float = 0.0
|
|
43
|
+
|
|
44
|
+
# Device scale factor
|
|
45
|
+
device_scale_factor: float = 1.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class BrowserBackend(Protocol):
|
|
50
|
+
"""
|
|
51
|
+
Minimal backend protocol for v0 proof-of-concept.
|
|
52
|
+
|
|
53
|
+
This is enough to:
|
|
54
|
+
- Take Sentience snapshots (DOM/geometry via extension)
|
|
55
|
+
- Execute JavaScript for element interaction
|
|
56
|
+
- Perform mouse operations (move, click, scroll)
|
|
57
|
+
- Wait for page stability
|
|
58
|
+
|
|
59
|
+
Implementers:
|
|
60
|
+
- CDPBackendV0: For browser-use integration via CDP
|
|
61
|
+
- PlaywrightBackend: Wrapper around existing SentienceBrowser (future)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
async def refresh_page_info(self) -> ViewportInfo:
|
|
65
|
+
"""
|
|
66
|
+
Cache viewport + scroll offsets + url; cheap & safe to call often.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
ViewportInfo with current viewport state
|
|
70
|
+
"""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
async def eval(self, expression: str) -> Any:
|
|
74
|
+
"""
|
|
75
|
+
Evaluate JavaScript expression in page context.
|
|
76
|
+
|
|
77
|
+
Uses CDP Runtime.evaluate with returnByValue=True.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
expression: JavaScript expression to evaluate
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Result value (JSON-serializable)
|
|
84
|
+
"""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
async def call(
|
|
88
|
+
self,
|
|
89
|
+
function_declaration: str,
|
|
90
|
+
args: list[Any] | None = None,
|
|
91
|
+
) -> Any:
|
|
92
|
+
"""
|
|
93
|
+
Call a JavaScript function with arguments.
|
|
94
|
+
|
|
95
|
+
Uses CDP Runtime.callFunctionOn for safe argument passing.
|
|
96
|
+
Safer than eval() for passing complex arguments.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
function_declaration: JavaScript function body, e.g., "(x, y) => x + y"
|
|
100
|
+
args: Arguments to pass to the function
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Result value (JSON-serializable)
|
|
104
|
+
"""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
async def get_layout_metrics(self) -> LayoutMetrics:
|
|
108
|
+
"""
|
|
109
|
+
Get page layout metrics.
|
|
110
|
+
|
|
111
|
+
Uses CDP Page.getLayoutMetrics to get viewport and content dimensions.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
LayoutMetrics with viewport and content size info
|
|
115
|
+
"""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
async def screenshot_png(self) -> bytes:
|
|
119
|
+
"""
|
|
120
|
+
Capture viewport screenshot as PNG bytes.
|
|
121
|
+
|
|
122
|
+
Uses CDP Page.captureScreenshot.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
PNG image bytes
|
|
126
|
+
"""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
async def mouse_move(self, x: float, y: float) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Move mouse to viewport coordinates.
|
|
132
|
+
|
|
133
|
+
Uses CDP Input.dispatchMouseEvent with type="mouseMoved".
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
x: X coordinate in viewport
|
|
137
|
+
y: Y coordinate in viewport
|
|
138
|
+
"""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
async def mouse_click(
|
|
142
|
+
self,
|
|
143
|
+
x: float,
|
|
144
|
+
y: float,
|
|
145
|
+
button: Literal["left", "right", "middle"] = "left",
|
|
146
|
+
click_count: int = 1,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""
|
|
149
|
+
Click at viewport coordinates.
|
|
150
|
+
|
|
151
|
+
Uses CDP Input.dispatchMouseEvent with mousePressed + mouseReleased.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
x: X coordinate in viewport
|
|
155
|
+
y: Y coordinate in viewport
|
|
156
|
+
button: Mouse button to click
|
|
157
|
+
click_count: Number of clicks (1 for single, 2 for double)
|
|
158
|
+
"""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
async def wheel(
|
|
162
|
+
self,
|
|
163
|
+
delta_y: float,
|
|
164
|
+
x: float | None = None,
|
|
165
|
+
y: float | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Scroll using mouse wheel.
|
|
169
|
+
|
|
170
|
+
Uses CDP Input.dispatchMouseEvent with type="mouseWheel".
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
delta_y: Scroll amount (positive = down, negative = up)
|
|
174
|
+
x: X coordinate for scroll (default: viewport center)
|
|
175
|
+
y: Y coordinate for scroll (default: viewport center)
|
|
176
|
+
"""
|
|
177
|
+
...
|
|
178
|
+
|
|
179
|
+
async def type_text(self, text: str) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Type text using keyboard input.
|
|
182
|
+
|
|
183
|
+
Uses CDP Input.dispatchKeyEvent for each character.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
text: Text to type
|
|
187
|
+
"""
|
|
188
|
+
...
|
|
189
|
+
|
|
190
|
+
async def wait_ready_state(
|
|
191
|
+
self,
|
|
192
|
+
state: Literal["interactive", "complete"] = "interactive",
|
|
193
|
+
timeout_ms: int = 15000,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Wait for document.readyState to reach target state.
|
|
197
|
+
|
|
198
|
+
Uses polling instead of CDP events (no leak from unregistered listeners).
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
state: Target state ("interactive" or "complete")
|
|
202
|
+
timeout_ms: Maximum time to wait in milliseconds
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
TimeoutError: If state not reached within timeout
|
|
206
|
+
"""
|
|
207
|
+
...
|
|
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
|
+
...
|