sentienceapi 0.92.2__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.

Files changed (64) hide show
  1. sentience/__init__.py +107 -2
  2. sentience/_extension_loader.py +156 -1
  3. sentience/action_executor.py +2 -0
  4. sentience/actions.py +354 -9
  5. sentience/agent.py +4 -0
  6. sentience/agent_runtime.py +840 -0
  7. sentience/asserts/__init__.py +70 -0
  8. sentience/asserts/expect.py +621 -0
  9. sentience/asserts/query.py +383 -0
  10. sentience/async_api.py +8 -1
  11. sentience/backends/__init__.py +137 -0
  12. sentience/backends/actions.py +372 -0
  13. sentience/backends/browser_use_adapter.py +241 -0
  14. sentience/backends/cdp_backend.py +393 -0
  15. sentience/backends/exceptions.py +211 -0
  16. sentience/backends/playwright_backend.py +194 -0
  17. sentience/backends/protocol.py +216 -0
  18. sentience/backends/sentience_context.py +469 -0
  19. sentience/backends/snapshot.py +483 -0
  20. sentience/browser.py +230 -74
  21. sentience/canonicalization.py +207 -0
  22. sentience/cloud_tracing.py +65 -24
  23. sentience/constants.py +6 -0
  24. sentience/cursor_policy.py +142 -0
  25. sentience/extension/content.js +35 -0
  26. sentience/extension/injected_api.js +310 -15
  27. sentience/extension/manifest.json +1 -1
  28. sentience/extension/pkg/sentience_core.d.ts +22 -22
  29. sentience/extension/pkg/sentience_core.js +192 -144
  30. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  31. sentience/extension/release.json +29 -29
  32. sentience/failure_artifacts.py +241 -0
  33. sentience/integrations/__init__.py +6 -0
  34. sentience/integrations/langchain/__init__.py +12 -0
  35. sentience/integrations/langchain/context.py +18 -0
  36. sentience/integrations/langchain/core.py +326 -0
  37. sentience/integrations/langchain/tools.py +180 -0
  38. sentience/integrations/models.py +46 -0
  39. sentience/integrations/pydanticai/__init__.py +15 -0
  40. sentience/integrations/pydanticai/deps.py +20 -0
  41. sentience/integrations/pydanticai/toolset.py +468 -0
  42. sentience/llm_provider.py +695 -18
  43. sentience/models.py +536 -3
  44. sentience/ordinal.py +280 -0
  45. sentience/query.py +66 -4
  46. sentience/schemas/trace_v1.json +27 -1
  47. sentience/snapshot.py +384 -93
  48. sentience/snapshot_diff.py +39 -54
  49. sentience/text_search.py +1 -0
  50. sentience/trace_event_builder.py +20 -1
  51. sentience/trace_indexing/indexer.py +3 -49
  52. sentience/tracer_factory.py +1 -3
  53. sentience/verification.py +618 -0
  54. sentience/visual_agent.py +3 -1
  55. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +198 -40
  56. sentienceapi-0.98.0.dist-info/RECORD +92 -0
  57. sentience/utils.py +0 -296
  58. sentienceapi-0.92.2.dist-info/RECORD +0 -65
  59. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
  60. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
  61. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
  62. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
  63. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
  64. {sentienceapi-0.92.2.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)