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,483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend-agnostic snapshot for browser-use integration.
|
|
3
|
+
|
|
4
|
+
Takes Sentience snapshots using BrowserBackend protocol,
|
|
5
|
+
enabling element grounding with browser-use or other frameworks.
|
|
6
|
+
|
|
7
|
+
Usage with browser-use:
|
|
8
|
+
from sentience.backends import BrowserUseAdapter, snapshot, CachedSnapshot
|
|
9
|
+
|
|
10
|
+
adapter = BrowserUseAdapter(session)
|
|
11
|
+
backend = await adapter.create_backend()
|
|
12
|
+
|
|
13
|
+
# Take snapshot
|
|
14
|
+
snap = await snapshot(backend)
|
|
15
|
+
print(f"Found {len(snap.elements)} elements")
|
|
16
|
+
|
|
17
|
+
# With caching (reuse if fresh)
|
|
18
|
+
cache = CachedSnapshot(backend, max_age_ms=2000)
|
|
19
|
+
snap1 = await cache.get() # Fresh snapshot
|
|
20
|
+
snap2 = await cache.get() # Returns cached if < 2s old
|
|
21
|
+
cache.invalidate() # Force refresh on next get()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import time
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from ..constants import SENTIENCE_API_URL
|
|
29
|
+
from ..models import Snapshot, SnapshotOptions
|
|
30
|
+
from ..snapshot import (
|
|
31
|
+
_build_snapshot_payload,
|
|
32
|
+
_merge_api_result_with_local,
|
|
33
|
+
_post_snapshot_to_gateway_async,
|
|
34
|
+
)
|
|
35
|
+
from .exceptions import ExtensionDiagnostics, ExtensionNotLoadedError, SnapshotError
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from .protocol import BrowserBackend
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_execution_context_destroyed_error(e: Exception) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Playwright (and other browser backends) can throw while a navigation is in-flight.
|
|
44
|
+
|
|
45
|
+
Common symptoms:
|
|
46
|
+
- "Execution context was destroyed, most likely because of a navigation"
|
|
47
|
+
- "Cannot find context with specified id"
|
|
48
|
+
"""
|
|
49
|
+
msg = str(e).lower()
|
|
50
|
+
return (
|
|
51
|
+
"execution context was destroyed" in msg
|
|
52
|
+
or "most likely because of a navigation" in msg
|
|
53
|
+
or "cannot find context with specified id" in msg
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _eval_with_navigation_retry(
|
|
58
|
+
backend: "BrowserBackend",
|
|
59
|
+
expression: str,
|
|
60
|
+
*,
|
|
61
|
+
retries: int = 10,
|
|
62
|
+
settle_state: str = "interactive",
|
|
63
|
+
settle_timeout_ms: int = 10000,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""
|
|
66
|
+
Evaluate JS, retrying once/ twice if the page is mid-navigation.
|
|
67
|
+
|
|
68
|
+
This makes snapshots resilient to cases like:
|
|
69
|
+
- press Enter (navigation) → snapshot immediately → context destroyed
|
|
70
|
+
"""
|
|
71
|
+
last_err: Exception | None = None
|
|
72
|
+
for attempt in range(retries + 1):
|
|
73
|
+
try:
|
|
74
|
+
return await backend.eval(expression)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
last_err = e
|
|
77
|
+
if not _is_execution_context_destroyed_error(e) or attempt >= retries:
|
|
78
|
+
raise
|
|
79
|
+
# Navigation is in-flight; wait for new document context then retry.
|
|
80
|
+
try:
|
|
81
|
+
await backend.wait_ready_state(state=settle_state, timeout_ms=settle_timeout_ms) # type: ignore[arg-type]
|
|
82
|
+
except Exception:
|
|
83
|
+
# If readyState polling also fails mid-nav, still retry after a short backoff.
|
|
84
|
+
pass
|
|
85
|
+
# Exponential-ish backoff (caps quickly), tuned for real navigations.
|
|
86
|
+
await asyncio.sleep(min(0.25 * (attempt + 1), 1.5))
|
|
87
|
+
|
|
88
|
+
# Unreachable in practice, but keeps type-checkers happy.
|
|
89
|
+
raise last_err if last_err else RuntimeError("eval failed")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CachedSnapshot:
|
|
93
|
+
"""
|
|
94
|
+
Snapshot cache with staleness detection.
|
|
95
|
+
|
|
96
|
+
Caches snapshots and returns cached version if still fresh.
|
|
97
|
+
Useful for reducing redundant snapshot calls in action loops.
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
cache = CachedSnapshot(backend, max_age_ms=2000)
|
|
101
|
+
|
|
102
|
+
# First call takes fresh snapshot
|
|
103
|
+
snap1 = await cache.get()
|
|
104
|
+
|
|
105
|
+
# Second call returns cached if < 2s old
|
|
106
|
+
snap2 = await cache.get()
|
|
107
|
+
|
|
108
|
+
# Invalidate after actions that change DOM
|
|
109
|
+
await click(backend, element.bbox)
|
|
110
|
+
cache.invalidate()
|
|
111
|
+
|
|
112
|
+
# Next get() will take fresh snapshot
|
|
113
|
+
snap3 = await cache.get()
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
backend: "BrowserBackend",
|
|
119
|
+
max_age_ms: int = 2000,
|
|
120
|
+
options: SnapshotOptions | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Initialize cached snapshot.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
backend: BrowserBackend implementation
|
|
127
|
+
max_age_ms: Maximum cache age in milliseconds (default: 2000)
|
|
128
|
+
options: Default snapshot options
|
|
129
|
+
"""
|
|
130
|
+
self._backend = backend
|
|
131
|
+
self._max_age_ms = max_age_ms
|
|
132
|
+
self._options = options
|
|
133
|
+
self._cached: Snapshot | None = None
|
|
134
|
+
self._cached_at: float = 0 # timestamp in seconds
|
|
135
|
+
self._cached_url: str | None = None
|
|
136
|
+
|
|
137
|
+
async def get(
|
|
138
|
+
self,
|
|
139
|
+
options: SnapshotOptions | None = None,
|
|
140
|
+
force_refresh: bool = False,
|
|
141
|
+
) -> Snapshot:
|
|
142
|
+
"""
|
|
143
|
+
Get snapshot, using cache if fresh.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
options: Override default options for this call
|
|
147
|
+
force_refresh: If True, always take fresh snapshot
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Snapshot (cached or fresh)
|
|
151
|
+
"""
|
|
152
|
+
# Check if we need to refresh
|
|
153
|
+
if force_refresh or self._is_stale():
|
|
154
|
+
self._cached = await snapshot(
|
|
155
|
+
self._backend,
|
|
156
|
+
options or self._options,
|
|
157
|
+
)
|
|
158
|
+
self._cached_at = time.time()
|
|
159
|
+
self._cached_url = self._cached.url
|
|
160
|
+
|
|
161
|
+
assert self._cached is not None
|
|
162
|
+
return self._cached
|
|
163
|
+
|
|
164
|
+
def invalidate(self) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Invalidate cache, forcing refresh on next get().
|
|
167
|
+
|
|
168
|
+
Call this after actions that modify the DOM.
|
|
169
|
+
"""
|
|
170
|
+
self._cached = None
|
|
171
|
+
self._cached_at = 0
|
|
172
|
+
self._cached_url = None
|
|
173
|
+
|
|
174
|
+
def _is_stale(self) -> bool:
|
|
175
|
+
"""Check if cache is stale and needs refresh."""
|
|
176
|
+
if self._cached is None:
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
# Check age
|
|
180
|
+
age_ms = (time.time() - self._cached_at) * 1000
|
|
181
|
+
if age_ms > self._max_age_ms:
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def is_cached(self) -> bool:
|
|
188
|
+
"""Check if a cached snapshot exists."""
|
|
189
|
+
return self._cached is not None
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def age_ms(self) -> float:
|
|
193
|
+
"""Get age of cached snapshot in milliseconds."""
|
|
194
|
+
if self._cached is None:
|
|
195
|
+
return float("inf")
|
|
196
|
+
return (time.time() - self._cached_at) * 1000
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def snapshot(
|
|
200
|
+
backend: "BrowserBackend",
|
|
201
|
+
options: SnapshotOptions | None = None,
|
|
202
|
+
) -> Snapshot:
|
|
203
|
+
"""
|
|
204
|
+
Take a Sentience snapshot using the backend protocol.
|
|
205
|
+
|
|
206
|
+
This function respects the `use_api` option and can call either:
|
|
207
|
+
- Server-side API (Pro/Enterprise tier) when `use_api=True` and API key is provided
|
|
208
|
+
- Local extension (Free tier) when `use_api=False` or no API key
|
|
209
|
+
|
|
210
|
+
Requires:
|
|
211
|
+
- Sentience extension loaded in browser (via --load-extension)
|
|
212
|
+
- Extension injected window.sentience API
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
backend: BrowserBackend implementation (CDPBackendV0, PlaywrightBackend, etc.)
|
|
216
|
+
options: Snapshot options (limit, filter, screenshot, use_api, sentience_api_key, etc.)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Snapshot with elements, viewport, and optional screenshot
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
from sentience.backends import BrowserUseAdapter
|
|
223
|
+
from sentience.backends.snapshot import snapshot
|
|
224
|
+
from sentience.models import SnapshotOptions
|
|
225
|
+
|
|
226
|
+
adapter = BrowserUseAdapter(session)
|
|
227
|
+
backend = await adapter.create_backend()
|
|
228
|
+
|
|
229
|
+
# Basic snapshot (uses local extension)
|
|
230
|
+
snap = await snapshot(backend)
|
|
231
|
+
|
|
232
|
+
# With server-side API (Pro/Enterprise tier)
|
|
233
|
+
snap = await snapshot(backend, SnapshotOptions(
|
|
234
|
+
use_api=True,
|
|
235
|
+
sentience_api_key="sk_pro_xxxxx",
|
|
236
|
+
limit=100,
|
|
237
|
+
screenshot=True
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
# Force local extension (Free tier)
|
|
241
|
+
snap = await snapshot(backend, SnapshotOptions(
|
|
242
|
+
use_api=False
|
|
243
|
+
))
|
|
244
|
+
"""
|
|
245
|
+
if options is None:
|
|
246
|
+
options = SnapshotOptions()
|
|
247
|
+
|
|
248
|
+
# Determine if we should use server-side API
|
|
249
|
+
# Same logic as main snapshot() function in sentience/snapshot.py
|
|
250
|
+
should_use_api = (
|
|
251
|
+
options.use_api if options.use_api is not None else (options.sentience_api_key is not None)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if should_use_api and options.sentience_api_key:
|
|
255
|
+
# Use server-side API (Pro/Enterprise tier)
|
|
256
|
+
return await _snapshot_via_api(backend, options)
|
|
257
|
+
else:
|
|
258
|
+
# Use local extension (Free tier)
|
|
259
|
+
return await _snapshot_via_extension(backend, options)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _wait_for_extension(
|
|
263
|
+
backend: "BrowserBackend",
|
|
264
|
+
timeout_ms: int = 5000,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""
|
|
267
|
+
Wait for Sentience extension to inject window.sentience API.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
backend: BrowserBackend implementation
|
|
271
|
+
timeout_ms: Maximum wait time
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
RuntimeError: If extension not injected within timeout
|
|
275
|
+
"""
|
|
276
|
+
import asyncio
|
|
277
|
+
import logging
|
|
278
|
+
|
|
279
|
+
logger = logging.getLogger("sentience.backends.snapshot")
|
|
280
|
+
|
|
281
|
+
start = time.monotonic()
|
|
282
|
+
timeout_sec = timeout_ms / 1000.0
|
|
283
|
+
poll_count = 0
|
|
284
|
+
|
|
285
|
+
logger.debug(f"Waiting for extension injection (timeout={timeout_ms}ms)...")
|
|
286
|
+
|
|
287
|
+
while True:
|
|
288
|
+
elapsed = time.monotonic() - start
|
|
289
|
+
poll_count += 1
|
|
290
|
+
|
|
291
|
+
if poll_count % 10 == 0: # Log every 10 polls (~1 second)
|
|
292
|
+
logger.debug(f"Extension poll #{poll_count}, elapsed={elapsed*1000:.0f}ms")
|
|
293
|
+
|
|
294
|
+
if elapsed >= timeout_sec:
|
|
295
|
+
# Gather diagnostics
|
|
296
|
+
try:
|
|
297
|
+
diag_dict = await backend.eval(
|
|
298
|
+
"""
|
|
299
|
+
(() => ({
|
|
300
|
+
sentience_defined: typeof window.sentience !== 'undefined',
|
|
301
|
+
sentience_snapshot: typeof window.sentience?.snapshot === 'function',
|
|
302
|
+
url: window.location.href,
|
|
303
|
+
extension_id: document.documentElement.dataset.sentienceExtensionId || null,
|
|
304
|
+
has_content_script: !!document.documentElement.dataset.sentienceExtensionId
|
|
305
|
+
}))()
|
|
306
|
+
"""
|
|
307
|
+
)
|
|
308
|
+
diagnostics = ExtensionDiagnostics.from_dict(diag_dict)
|
|
309
|
+
logger.debug(f"Extension diagnostics: {diag_dict}")
|
|
310
|
+
except Exception as e:
|
|
311
|
+
diagnostics = ExtensionDiagnostics(error=f"Could not gather diagnostics: {e}")
|
|
312
|
+
|
|
313
|
+
raise ExtensionNotLoadedError.from_timeout(
|
|
314
|
+
timeout_ms=timeout_ms,
|
|
315
|
+
diagnostics=diagnostics,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Check if extension is ready
|
|
319
|
+
try:
|
|
320
|
+
ready = await backend.eval(
|
|
321
|
+
"typeof window.sentience !== 'undefined' && "
|
|
322
|
+
"typeof window.sentience.snapshot === 'function'"
|
|
323
|
+
)
|
|
324
|
+
if ready:
|
|
325
|
+
return
|
|
326
|
+
except Exception:
|
|
327
|
+
pass # Keep polling
|
|
328
|
+
|
|
329
|
+
await asyncio.sleep(0.1)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def _snapshot_via_extension(
|
|
333
|
+
backend: "BrowserBackend",
|
|
334
|
+
options: SnapshotOptions,
|
|
335
|
+
) -> Snapshot:
|
|
336
|
+
"""Take snapshot using local extension (Free tier)"""
|
|
337
|
+
# Wait for extension injection
|
|
338
|
+
await _wait_for_extension(backend, timeout_ms=5000)
|
|
339
|
+
|
|
340
|
+
# Build options dict for extension API
|
|
341
|
+
ext_options = _build_extension_options(options)
|
|
342
|
+
|
|
343
|
+
# Call extension's snapshot function
|
|
344
|
+
result = await _eval_with_navigation_retry(
|
|
345
|
+
backend,
|
|
346
|
+
f"""
|
|
347
|
+
(() => {{
|
|
348
|
+
const options = {_json_serialize(ext_options)};
|
|
349
|
+
return window.sentience.snapshot(options);
|
|
350
|
+
}})()
|
|
351
|
+
""",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if result is None:
|
|
355
|
+
# Try to get URL for better error message
|
|
356
|
+
try:
|
|
357
|
+
url = await backend.eval("window.location.href")
|
|
358
|
+
except Exception:
|
|
359
|
+
url = None
|
|
360
|
+
raise SnapshotError.from_null_result(url=url)
|
|
361
|
+
|
|
362
|
+
# Show overlay if requested
|
|
363
|
+
if options.show_overlay:
|
|
364
|
+
raw_elements = result.get("raw_elements", [])
|
|
365
|
+
if raw_elements:
|
|
366
|
+
await _eval_with_navigation_retry(
|
|
367
|
+
backend,
|
|
368
|
+
f"""
|
|
369
|
+
(() => {{
|
|
370
|
+
if (window.sentience && window.sentience.showOverlay) {{
|
|
371
|
+
window.sentience.showOverlay({_json_serialize(raw_elements)}, null);
|
|
372
|
+
}}
|
|
373
|
+
}})()
|
|
374
|
+
""",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Build and return Snapshot
|
|
378
|
+
return Snapshot(**result)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
async def _snapshot_via_api(
|
|
382
|
+
backend: "BrowserBackend",
|
|
383
|
+
options: SnapshotOptions,
|
|
384
|
+
) -> Snapshot:
|
|
385
|
+
"""Take snapshot using server-side API (Pro/Enterprise tier)"""
|
|
386
|
+
# Default API URL (same as main snapshot function)
|
|
387
|
+
api_url = SENTIENCE_API_URL
|
|
388
|
+
|
|
389
|
+
# Wait for extension injection (needed even for API mode to collect raw data)
|
|
390
|
+
await _wait_for_extension(backend, timeout_ms=5000)
|
|
391
|
+
|
|
392
|
+
# Step 1: Get raw data from local extension (always happens locally)
|
|
393
|
+
raw_options: dict[str, Any] = {}
|
|
394
|
+
if options.screenshot is not False:
|
|
395
|
+
raw_options["screenshot"] = options.screenshot
|
|
396
|
+
|
|
397
|
+
# Call extension to get raw elements
|
|
398
|
+
raw_result = await _eval_with_navigation_retry(
|
|
399
|
+
backend,
|
|
400
|
+
f"""
|
|
401
|
+
(() => {{
|
|
402
|
+
const options = {_json_serialize(raw_options)};
|
|
403
|
+
return window.sentience.snapshot(options);
|
|
404
|
+
}})()
|
|
405
|
+
""",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if raw_result is None:
|
|
409
|
+
try:
|
|
410
|
+
url = await backend.eval("window.location.href")
|
|
411
|
+
except Exception:
|
|
412
|
+
url = None
|
|
413
|
+
raise SnapshotError.from_null_result(url=url)
|
|
414
|
+
|
|
415
|
+
# Step 2: Send to server for smart ranking/filtering
|
|
416
|
+
payload = _build_snapshot_payload(raw_result, options)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
api_result = await _post_snapshot_to_gateway_async(
|
|
420
|
+
payload, options.sentience_api_key, api_url
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Merge API result with local data (screenshot, etc.)
|
|
424
|
+
snapshot_data = _merge_api_result_with_local(api_result, raw_result)
|
|
425
|
+
|
|
426
|
+
# Show visual overlay if requested (use API-ranked elements)
|
|
427
|
+
if options.show_overlay:
|
|
428
|
+
elements = api_result.get("elements", [])
|
|
429
|
+
if elements:
|
|
430
|
+
await _eval_with_navigation_retry(
|
|
431
|
+
backend,
|
|
432
|
+
f"""
|
|
433
|
+
(() => {{
|
|
434
|
+
if (window.sentience && window.sentience.showOverlay) {{
|
|
435
|
+
window.sentience.showOverlay({_json_serialize(elements)}, null);
|
|
436
|
+
}}
|
|
437
|
+
}})()
|
|
438
|
+
""",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return Snapshot(**snapshot_data)
|
|
442
|
+
except (RuntimeError, ValueError):
|
|
443
|
+
# Re-raise validation errors as-is
|
|
444
|
+
raise
|
|
445
|
+
except Exception as e:
|
|
446
|
+
# Fallback to local extension on API error
|
|
447
|
+
# This matches the behavior of the main snapshot function
|
|
448
|
+
raise RuntimeError(
|
|
449
|
+
f"Server-side snapshot API failed: {e}. "
|
|
450
|
+
"Try using use_api=False to use local extension instead."
|
|
451
|
+
) from e
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _build_extension_options(options: SnapshotOptions) -> dict[str, Any]:
|
|
455
|
+
"""Build options dict for extension API call."""
|
|
456
|
+
ext_options: dict[str, Any] = {}
|
|
457
|
+
|
|
458
|
+
# Screenshot config
|
|
459
|
+
if options.screenshot is not False:
|
|
460
|
+
if hasattr(options.screenshot, "model_dump"):
|
|
461
|
+
ext_options["screenshot"] = options.screenshot.model_dump()
|
|
462
|
+
else:
|
|
463
|
+
ext_options["screenshot"] = options.screenshot
|
|
464
|
+
|
|
465
|
+
# Limit (only if not default)
|
|
466
|
+
if options.limit != 50:
|
|
467
|
+
ext_options["limit"] = options.limit
|
|
468
|
+
|
|
469
|
+
# Filter
|
|
470
|
+
if options.filter is not None:
|
|
471
|
+
if hasattr(options.filter, "model_dump"):
|
|
472
|
+
ext_options["filter"] = options.filter.model_dump()
|
|
473
|
+
else:
|
|
474
|
+
ext_options["filter"] = options.filter
|
|
475
|
+
|
|
476
|
+
return ext_options
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _json_serialize(obj: Any) -> str:
|
|
480
|
+
"""Serialize object to JSON string for embedding in JS."""
|
|
481
|
+
import json
|
|
482
|
+
|
|
483
|
+
return json.dumps(obj)
|
sentience/base_agent.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
1
3
|
"""
|
|
2
4
|
BaseAgent: Abstract base class for all Sentience agents
|
|
3
5
|
Defines the interface that all agent implementations must follow
|
|
@@ -99,3 +101,96 @@ class BaseAgent(ABC):
|
|
|
99
101
|
>>> # filtered now contains only relevant elements
|
|
100
102
|
"""
|
|
101
103
|
return snapshot.elements
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BaseAgentAsync(ABC):
|
|
107
|
+
"""
|
|
108
|
+
Abstract base class for all async Sentience agents.
|
|
109
|
+
|
|
110
|
+
Provides a standard interface for:
|
|
111
|
+
- Executing natural language goals (act)
|
|
112
|
+
- Tracking execution history
|
|
113
|
+
- Monitoring token usage
|
|
114
|
+
- Filtering elements based on goals
|
|
115
|
+
|
|
116
|
+
Subclasses must implement:
|
|
117
|
+
- act(): Execute a natural language goal (async)
|
|
118
|
+
- get_history(): Return execution history
|
|
119
|
+
- get_token_stats(): Return token usage statistics
|
|
120
|
+
- clear_history(): Reset history and token counters
|
|
121
|
+
|
|
122
|
+
Subclasses can override:
|
|
123
|
+
- filter_elements(): Customize element filtering logic
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
async def act(self, goal: str, **kwargs) -> AgentActionResult:
|
|
128
|
+
"""
|
|
129
|
+
Execute a natural language goal using the agent (async).
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
goal: Natural language instruction (e.g., "Click the login button")
|
|
133
|
+
**kwargs: Additional parameters (implementation-specific)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
AgentActionResult with execution details
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
RuntimeError: If execution fails after retries
|
|
140
|
+
"""
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def get_history(self) -> list[ActionHistory]:
|
|
145
|
+
"""
|
|
146
|
+
Get the execution history of all actions taken.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
List of ActionHistory entries
|
|
150
|
+
"""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
@abstractmethod
|
|
154
|
+
def get_token_stats(self) -> TokenStats:
|
|
155
|
+
"""
|
|
156
|
+
Get token usage statistics for the agent session.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
TokenStats with cumulative token counts
|
|
160
|
+
"""
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def clear_history(self) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Clear execution history and reset token counters.
|
|
167
|
+
|
|
168
|
+
This resets the agent to a clean state.
|
|
169
|
+
"""
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def filter_elements(self, snapshot: Snapshot, goal: str | None = None) -> list[Element]:
|
|
173
|
+
"""
|
|
174
|
+
Filter elements from a snapshot based on goal context.
|
|
175
|
+
|
|
176
|
+
Default implementation returns all elements unchanged.
|
|
177
|
+
Subclasses can override to implement custom filtering logic
|
|
178
|
+
such as:
|
|
179
|
+
- Removing irrelevant elements based on goal keywords
|
|
180
|
+
- Boosting importance of matching elements
|
|
181
|
+
- Filtering by role, size, or visual properties
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
snapshot: Current page snapshot
|
|
185
|
+
goal: User's goal (can inform filtering strategy)
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Filtered list of elements (default: all elements)
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
>>> agent = SentienceAgentAsync(browser, llm)
|
|
192
|
+
>>> snap = await snapshot_async(browser)
|
|
193
|
+
>>> filtered = agent.filter_elements(snap, goal="Click login")
|
|
194
|
+
>>> # filtered now contains only relevant elements
|
|
195
|
+
"""
|
|
196
|
+
return snapshot.elements
|