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
sentience/snapshot.py CHANGED
@@ -12,6 +12,7 @@ import requests
12
12
 
13
13
  from .browser import AsyncSentienceBrowser, SentienceBrowser
14
14
  from .browser_evaluator import BrowserEvaluator
15
+ from .constants import SENTIENCE_API_URL
15
16
  from .models import Snapshot, SnapshotOptions
16
17
  from .sentience_methods import SentienceMethod
17
18
 
@@ -19,6 +20,206 @@ from .sentience_methods import SentienceMethod
19
20
  MAX_PAYLOAD_BYTES = 10 * 1024 * 1024
20
21
 
21
22
 
23
+ def _is_execution_context_destroyed_error(e: Exception) -> bool:
24
+ """
25
+ Playwright can throw while a navigation is in-flight, invalidating the JS execution context.
26
+
27
+ Common symptoms:
28
+ - "Execution context was destroyed, most likely because of a navigation"
29
+ - "Cannot find context with specified id"
30
+ """
31
+ msg = str(e).lower()
32
+ return (
33
+ "execution context was destroyed" in msg
34
+ or "most likely because of a navigation" in msg
35
+ or "cannot find context with specified id" in msg
36
+ )
37
+
38
+
39
+ async def _page_evaluate_with_nav_retry(
40
+ page: Any,
41
+ expression: str,
42
+ arg: Any = None,
43
+ *,
44
+ retries: int = 2,
45
+ settle_timeout_ms: int = 10000,
46
+ ) -> Any:
47
+ """
48
+ Evaluate JS with a small retry loop if the page is mid-navigation.
49
+
50
+ This prevents flaky crashes when callers snapshot right after triggering a navigation
51
+ (e.g., pressing Enter on Google).
52
+ """
53
+ last_err: Exception | None = None
54
+ for attempt in range(retries + 1):
55
+ try:
56
+ if arg is None:
57
+ return await page.evaluate(expression)
58
+ return await page.evaluate(expression, arg)
59
+ except Exception as e:
60
+ last_err = e
61
+ if not _is_execution_context_destroyed_error(e) or attempt >= retries:
62
+ raise
63
+ try:
64
+ await page.wait_for_load_state("domcontentloaded", timeout=settle_timeout_ms)
65
+ except Exception:
66
+ pass
67
+ await asyncio.sleep(0.25)
68
+ raise last_err if last_err else RuntimeError("Page.evaluate failed")
69
+
70
+
71
+ async def _wait_for_function_with_nav_retry(
72
+ page: Any,
73
+ expression: str,
74
+ *,
75
+ timeout_ms: int,
76
+ retries: int = 2,
77
+ ) -> None:
78
+ last_err: Exception | None = None
79
+ for attempt in range(retries + 1):
80
+ try:
81
+ await page.wait_for_function(expression, timeout=timeout_ms)
82
+ return
83
+ except Exception as e:
84
+ last_err = e
85
+ if not _is_execution_context_destroyed_error(e) or attempt >= retries:
86
+ raise
87
+ try:
88
+ await page.wait_for_load_state("domcontentloaded", timeout=timeout_ms)
89
+ except Exception:
90
+ pass
91
+ await asyncio.sleep(0.25)
92
+ raise last_err if last_err else RuntimeError("wait_for_function failed")
93
+
94
+
95
+ def _build_snapshot_payload(
96
+ raw_result: dict[str, Any],
97
+ options: SnapshotOptions,
98
+ ) -> dict[str, Any]:
99
+ """
100
+ Build payload dict for gateway snapshot API.
101
+
102
+ Shared helper used by both sync and async snapshot implementations.
103
+ """
104
+ diagnostics = raw_result.get("diagnostics") or {}
105
+ client_metrics = None
106
+ try:
107
+ client_metrics = diagnostics.get("metrics")
108
+ except Exception:
109
+ client_metrics = None
110
+
111
+ return {
112
+ "raw_elements": raw_result.get("raw_elements", []),
113
+ "url": raw_result.get("url", ""),
114
+ "viewport": raw_result.get("viewport"),
115
+ "goal": options.goal,
116
+ "options": {
117
+ "limit": options.limit,
118
+ "filter": options.filter.model_dump() if options.filter else None,
119
+ },
120
+ "client_metrics": client_metrics,
121
+ }
122
+
123
+
124
+ def _validate_payload_size(payload_json: str) -> None:
125
+ """
126
+ Validate payload size before sending to gateway.
127
+
128
+ Raises ValueError if payload exceeds server limit.
129
+ """
130
+ payload_size = len(payload_json.encode("utf-8"))
131
+ if payload_size > MAX_PAYLOAD_BYTES:
132
+ raise ValueError(
133
+ f"Payload size ({payload_size / 1024 / 1024:.2f}MB) exceeds server limit "
134
+ f"({MAX_PAYLOAD_BYTES / 1024 / 1024:.0f}MB). "
135
+ f"Try reducing the number of elements on the page or filtering elements."
136
+ )
137
+
138
+
139
+ def _post_snapshot_to_gateway_sync(
140
+ payload: dict[str, Any],
141
+ api_key: str,
142
+ api_url: str = SENTIENCE_API_URL,
143
+ ) -> dict[str, Any]:
144
+ """
145
+ Post snapshot payload to gateway (synchronous).
146
+
147
+ Used by sync snapshot() function.
148
+ """
149
+ payload_json = json.dumps(payload)
150
+ _validate_payload_size(payload_json)
151
+
152
+ headers = {
153
+ "Authorization": f"Bearer {api_key}",
154
+ "Content-Type": "application/json",
155
+ }
156
+
157
+ response = requests.post(
158
+ f"{api_url}/v1/snapshot",
159
+ data=payload_json,
160
+ headers=headers,
161
+ timeout=30,
162
+ )
163
+ response.raise_for_status()
164
+ return response.json()
165
+
166
+
167
+ async def _post_snapshot_to_gateway_async(
168
+ payload: dict[str, Any],
169
+ api_key: str,
170
+ api_url: str = SENTIENCE_API_URL,
171
+ ) -> dict[str, Any]:
172
+ """
173
+ Post snapshot payload to gateway (asynchronous).
174
+
175
+ Used by async backend snapshot() function.
176
+ """
177
+ # Lazy import httpx - only needed for async API calls
178
+ import httpx
179
+
180
+ payload_json = json.dumps(payload)
181
+ _validate_payload_size(payload_json)
182
+
183
+ headers = {
184
+ "Authorization": f"Bearer {api_key}",
185
+ "Content-Type": "application/json",
186
+ }
187
+
188
+ async with httpx.AsyncClient(timeout=30.0) as client:
189
+ response = await client.post(
190
+ f"{api_url}/v1/snapshot",
191
+ content=payload_json,
192
+ headers=headers,
193
+ )
194
+ response.raise_for_status()
195
+ return response.json()
196
+
197
+
198
+ def _merge_api_result_with_local(
199
+ api_result: dict[str, Any],
200
+ raw_result: dict[str, Any],
201
+ ) -> dict[str, Any]:
202
+ """
203
+ Merge API result with local data (screenshot, etc.).
204
+
205
+ Shared helper used by both sync and async snapshot implementations.
206
+ """
207
+ return {
208
+ "status": api_result.get("status", "success"),
209
+ "timestamp": api_result.get("timestamp"),
210
+ "url": api_result.get("url", raw_result.get("url", "")),
211
+ "viewport": api_result.get("viewport", raw_result.get("viewport")),
212
+ "elements": api_result.get("elements", []),
213
+ "screenshot": raw_result.get("screenshot"), # Keep local screenshot
214
+ "screenshot_format": raw_result.get("screenshot_format"),
215
+ "error": api_result.get("error"),
216
+ # Phase 2: Runtime stability/debug info
217
+ "diagnostics": api_result.get("diagnostics", raw_result.get("diagnostics")),
218
+ # Phase 2: Ordinal support - dominant group key from Gateway
219
+ "dominant_group_key": api_result.get("dominant_group_key"),
220
+ }
221
+
222
+
22
223
  def _save_trace_to_file(raw_elements: list[dict[str, Any]], trace_path: str | None = None) -> None:
23
224
  """
24
225
  Save raw_elements to a JSON file for benchmarking/training
@@ -72,14 +273,18 @@ def snapshot(
72
273
  if options is None:
73
274
  options = SnapshotOptions()
74
275
 
276
+ # Resolve API key: options.sentience_api_key takes precedence, then browser.api_key
277
+ # This allows browser-use users to pass api_key via options without SentienceBrowser
278
+ effective_api_key = options.sentience_api_key or browser.api_key
279
+
75
280
  # Determine if we should use server-side API
76
281
  should_use_api = (
77
- options.use_api if options.use_api is not None else (browser.api_key is not None)
282
+ options.use_api if options.use_api is not None else (effective_api_key is not None)
78
283
  )
79
284
 
80
- if should_use_api and browser.api_key:
285
+ if should_use_api and effective_api_key:
81
286
  # Use server-side API (Pro/Enterprise tier)
82
- return _snapshot_via_api(browser, options)
287
+ return _snapshot_via_api(browser, options, effective_api_key)
83
288
  else:
84
289
  # Use local extension (Free tier)
85
290
  return _snapshot_via_extension(browser, options)
@@ -127,10 +332,15 @@ def _snapshot_via_extension(
127
332
  if options.save_trace:
128
333
  _save_trace_to_file(result.get("raw_elements", []), options.trace_path)
129
334
 
335
+ # Validate and parse with Pydantic
336
+ snapshot_obj = Snapshot(**result)
337
+
130
338
  # Show visual overlay if requested
131
339
  if options.show_overlay:
132
- raw_elements = result.get("raw_elements", [])
133
- if raw_elements:
340
+ # Prefer processed semantic elements for overlay (have bbox/importance/visual_cues).
341
+ # raw_elements may not match the overlay renderer's expected shape.
342
+ elements_for_overlay = result.get("elements") or result.get("raw_elements") or []
343
+ if elements_for_overlay:
134
344
  browser.page.evaluate(
135
345
  """
136
346
  (elements) => {
@@ -139,27 +349,46 @@ def _snapshot_via_extension(
139
349
  }
140
350
  }
141
351
  """,
142
- raw_elements,
352
+ elements_for_overlay,
353
+ )
354
+
355
+ # Show grid overlay if requested
356
+ if options.show_grid:
357
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
358
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
359
+ if grids:
360
+ # Convert GridInfo to dict for JavaScript
361
+ grid_dicts = [grid.model_dump() for grid in grids]
362
+ # Pass grid_id as targetGridId to highlight it in red
363
+ target_grid_id = options.grid_id if options.grid_id is not None else None
364
+ browser.page.evaluate(
365
+ """
366
+ (grids, targetGridId) => {
367
+ if (window.sentience && window.sentience.showGrid) {
368
+ window.sentience.showGrid(grids, targetGridId);
369
+ } else {
370
+ console.warn('[SDK] showGrid not available in extension');
371
+ }
372
+ }
373
+ """,
374
+ grid_dicts,
375
+ target_grid_id,
143
376
  )
144
377
 
145
- # Validate and parse with Pydantic
146
- snapshot_obj = Snapshot(**result)
147
378
  return snapshot_obj
148
379
 
149
380
 
150
381
  def _snapshot_via_api(
151
382
  browser: SentienceBrowser,
152
383
  options: SnapshotOptions,
384
+ api_key: str,
153
385
  ) -> Snapshot:
154
386
  """Take snapshot using server-side API (Pro/Enterprise tier)"""
155
387
  if not browser.page:
156
388
  raise RuntimeError("Browser not started. Call browser.start() first.")
157
389
 
158
- if not browser.api_key:
159
- raise ValueError("API key required for server-side processing")
160
-
161
- if not browser.api_url:
162
- raise ValueError("API URL required for server-side processing")
390
+ # Use browser.api_url if set, otherwise default
391
+ api_url = browser.api_url or SENTIENCE_API_URL
163
392
 
164
393
  # CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
165
394
  # Even for API mode, we need the extension to collect raw data locally
@@ -169,6 +398,14 @@ def _snapshot_via_api(
169
398
  raw_options: dict[str, Any] = {}
170
399
  if options.screenshot is not False:
171
400
  raw_options["screenshot"] = options.screenshot
401
+ # Important: also pass limit/filter to extension to keep raw_elements payload bounded.
402
+ # Without this, large pages (e.g. Amazon) can exceed gateway request size limits (HTTP 413).
403
+ if options.limit != 50:
404
+ raw_options["limit"] = options.limit
405
+ if options.filter is not None:
406
+ raw_options["filter"] = (
407
+ options.filter.model_dump() if hasattr(options.filter, "model_dump") else options.filter
408
+ )
172
409
 
173
410
  raw_result = BrowserEvaluator.invoke(browser.page, SentienceMethod.SNAPSHOT, **raw_options)
174
411
 
@@ -179,54 +416,16 @@ def _snapshot_via_api(
179
416
  # Step 2: Send to server for smart ranking/filtering
180
417
  # Use raw_elements (raw data) instead of elements (processed data)
181
418
  # Server validates API key and applies proprietary ranking logic
182
- payload = {
183
- "raw_elements": raw_result.get("raw_elements", []), # Raw data needed for server processing
184
- "url": raw_result.get("url", ""),
185
- "viewport": raw_result.get("viewport"),
186
- "goal": options.goal, # Optional goal/task description
187
- "options": {
188
- "limit": options.limit,
189
- "filter": options.filter.model_dump() if options.filter else None,
190
- },
191
- }
192
-
193
- # Check payload size before sending (server has 10MB limit)
194
- payload_json = json.dumps(payload)
195
- payload_size = len(payload_json.encode("utf-8"))
196
- if payload_size > MAX_PAYLOAD_BYTES:
197
- raise ValueError(
198
- f"Payload size ({payload_size / 1024 / 1024:.2f}MB) exceeds server limit "
199
- f"({MAX_PAYLOAD_BYTES / 1024 / 1024:.0f}MB). "
200
- f"Try reducing the number of elements on the page or filtering elements."
201
- )
202
-
203
- headers = {
204
- "Authorization": f"Bearer {browser.api_key}",
205
- "Content-Type": "application/json",
206
- }
419
+ payload = _build_snapshot_payload(raw_result, options)
207
420
 
208
421
  try:
209
- response = requests.post(
210
- f"{browser.api_url}/v1/snapshot",
211
- data=payload_json, # Reuse already-serialized JSON
212
- headers=headers,
213
- timeout=30,
214
- )
215
- response.raise_for_status()
216
-
217
- api_result = response.json()
422
+ api_result = _post_snapshot_to_gateway_sync(payload, api_key, api_url)
218
423
 
219
424
  # Merge API result with local data (screenshot, etc.)
220
- snapshot_data = {
221
- "status": api_result.get("status", "success"),
222
- "timestamp": api_result.get("timestamp"),
223
- "url": api_result.get("url", raw_result.get("url", "")),
224
- "viewport": api_result.get("viewport", raw_result.get("viewport")),
225
- "elements": api_result.get("elements", []),
226
- "screenshot": raw_result.get("screenshot"), # Keep local screenshot
227
- "screenshot_format": raw_result.get("screenshot_format"),
228
- "error": api_result.get("error"),
229
- }
425
+ snapshot_data = _merge_api_result_with_local(api_result, raw_result)
426
+
427
+ # Create snapshot object
428
+ snapshot_obj = Snapshot(**snapshot_data)
230
429
 
231
430
  # Show visual overlay if requested (use API-ranked elements)
232
431
  if options.show_overlay:
@@ -243,9 +442,31 @@ def _snapshot_via_api(
243
442
  elements,
244
443
  )
245
444
 
246
- return Snapshot(**snapshot_data)
445
+ # Show grid overlay if requested
446
+ if options.show_grid:
447
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
448
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
449
+ if grids:
450
+ grid_dicts = [grid.model_dump() for grid in grids]
451
+ # Pass grid_id as targetGridId to highlight it in red
452
+ target_grid_id = options.grid_id if options.grid_id is not None else None
453
+ browser.page.evaluate(
454
+ """
455
+ (grids, targetGridId) => {
456
+ if (window.sentience && window.sentience.showGrid) {
457
+ window.sentience.showGrid(grids, targetGridId);
458
+ } else {
459
+ console.warn('[SDK] showGrid not available in extension');
460
+ }
461
+ }
462
+ """,
463
+ grid_dicts,
464
+ target_grid_id,
465
+ )
466
+
467
+ return snapshot_obj
247
468
  except requests.exceptions.RequestException as e:
248
- raise RuntimeError(f"API request failed: {e}")
469
+ raise RuntimeError(f"API request failed: {e}") from e
249
470
 
250
471
 
251
472
  # ========== Async Snapshot Functions ==========
@@ -281,14 +502,18 @@ async def snapshot_async(
281
502
  if options is None:
282
503
  options = SnapshotOptions()
283
504
 
505
+ # Resolve API key: options.sentience_api_key takes precedence, then browser.api_key
506
+ # This allows browser-use users to pass api_key via options without SentienceBrowser
507
+ effective_api_key = options.sentience_api_key or browser.api_key
508
+
284
509
  # Determine if we should use server-side API
285
510
  should_use_api = (
286
- options.use_api if options.use_api is not None else (browser.api_key is not None)
511
+ options.use_api if options.use_api is not None else (effective_api_key is not None)
287
512
  )
288
513
 
289
- if should_use_api and browser.api_key:
514
+ if should_use_api and effective_api_key:
290
515
  # Use server-side API (Pro/Enterprise tier)
291
- return await _snapshot_via_api_async(browser, options)
516
+ return await _snapshot_via_api_async(browser, options, effective_api_key)
292
517
  else:
293
518
  # Use local extension (Free tier)
294
519
  return await _snapshot_via_extension_async(browser, options)
@@ -304,18 +529,20 @@ async def _snapshot_via_extension_async(
304
529
 
305
530
  # Wait for extension injection to complete
306
531
  try:
307
- await browser.page.wait_for_function(
532
+ await _wait_for_function_with_nav_retry(
533
+ browser.page,
308
534
  "typeof window.sentience !== 'undefined'",
309
- timeout=5000,
535
+ timeout_ms=5000,
310
536
  )
311
537
  except Exception as e:
312
538
  try:
313
- diag = await browser.page.evaluate(
539
+ diag = await _page_evaluate_with_nav_retry(
540
+ browser.page,
314
541
  """() => ({
315
542
  sentience_defined: typeof window.sentience !== 'undefined',
316
543
  extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
317
544
  url: window.location.href
318
- })"""
545
+ })""",
319
546
  )
320
547
  except Exception:
321
548
  diag = {"error": "Could not gather diagnostics"}
@@ -341,7 +568,8 @@ async def _snapshot_via_extension_async(
341
568
  )
342
569
 
343
570
  # Call extension API
344
- result = await browser.page.evaluate(
571
+ result = await _page_evaluate_with_nav_retry(
572
+ browser.page,
345
573
  """
346
574
  (options) => {
347
575
  return window.sentience.snapshot(options);
@@ -356,11 +584,26 @@ async def _snapshot_via_extension_async(
356
584
  if options.save_trace:
357
585
  _save_trace_to_file(result.get("raw_elements", []), options.trace_path)
358
586
 
587
+ # Extract screenshot_format from data URL if not provided by extension
588
+ if result.get("screenshot") and not result.get("screenshot_format"):
589
+ screenshot_data_url = result.get("screenshot", "")
590
+ if screenshot_data_url.startswith("data:image/"):
591
+ # Extract format from "data:image/jpeg;base64,..." or "data:image/png;base64,..."
592
+ format_match = screenshot_data_url.split(";")[0].split("/")[-1]
593
+ if format_match in ["jpeg", "jpg", "png"]:
594
+ result["screenshot_format"] = "jpeg" if format_match in ["jpeg", "jpg"] else "png"
595
+
596
+ # Validate and parse with Pydantic
597
+ snapshot_obj = Snapshot(**result)
598
+
359
599
  # Show visual overlay if requested
360
600
  if options.show_overlay:
361
- raw_elements = result.get("raw_elements", [])
362
- if raw_elements:
363
- await browser.page.evaluate(
601
+ # Prefer processed semantic elements for overlay (have bbox/importance/visual_cues).
602
+ # raw_elements may not match the overlay renderer's expected shape.
603
+ elements_for_overlay = result.get("elements") or result.get("raw_elements") or []
604
+ if elements_for_overlay:
605
+ await _page_evaluate_with_nav_retry(
606
+ browser.page,
364
607
  """
365
608
  (elements) => {
366
609
  if (window.sentience && window.sentience.showOverlay) {
@@ -368,41 +611,53 @@ async def _snapshot_via_extension_async(
368
611
  }
369
612
  }
370
613
  """,
371
- raw_elements,
614
+ elements_for_overlay,
372
615
  )
373
616
 
374
- # Extract screenshot_format from data URL if not provided by extension
375
- if result.get("screenshot") and not result.get("screenshot_format"):
376
- screenshot_data_url = result.get("screenshot", "")
377
- if screenshot_data_url.startswith("data:image/"):
378
- # Extract format from "data:image/jpeg;base64,..." or "data:image/png;base64,..."
379
- format_match = screenshot_data_url.split(";")[0].split("/")[-1]
380
- if format_match in ["jpeg", "jpg", "png"]:
381
- result["screenshot_format"] = "jpeg" if format_match in ["jpeg", "jpg"] else "png"
617
+ # Show grid overlay if requested
618
+ if options.show_grid:
619
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
620
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
621
+ if grids:
622
+ grid_dicts = [grid.model_dump() for grid in grids]
623
+ # Pass grid_id as targetGridId to highlight it in red
624
+ target_grid_id = options.grid_id if options.grid_id is not None else None
625
+ await _page_evaluate_with_nav_retry(
626
+ browser.page,
627
+ """
628
+ (args) => {
629
+ const [grids, targetGridId] = args;
630
+ if (window.sentience && window.sentience.showGrid) {
631
+ window.sentience.showGrid(grids, targetGridId);
632
+ } else {
633
+ console.warn('[SDK] showGrid not available in extension');
634
+ }
635
+ }
636
+ """,
637
+ [grid_dicts, target_grid_id],
638
+ )
382
639
 
383
- # Validate and parse with Pydantic
384
- snapshot_obj = Snapshot(**result)
385
640
  return snapshot_obj
386
641
 
387
642
 
388
643
  async def _snapshot_via_api_async(
389
644
  browser: AsyncSentienceBrowser,
390
645
  options: SnapshotOptions,
646
+ api_key: str,
391
647
  ) -> Snapshot:
392
648
  """Take snapshot using server-side API (Pro/Enterprise tier) - async"""
393
649
  if not browser.page:
394
650
  raise RuntimeError("Browser not started. Call await browser.start() first.")
395
651
 
396
- if not browser.api_key:
397
- raise ValueError("API key required for server-side processing")
398
-
399
- if not browser.api_url:
400
- raise ValueError("API URL required for server-side processing")
652
+ # Use browser.api_url if set, otherwise default
653
+ api_url = browser.api_url or SENTIENCE_API_URL
401
654
 
402
655
  # Wait for extension injection
403
656
  try:
404
- await browser.page.wait_for_function(
405
- "typeof window.sentience !== 'undefined'", timeout=5000
657
+ await _wait_for_function_with_nav_retry(
658
+ browser.page,
659
+ "typeof window.sentience !== 'undefined'",
660
+ timeout_ms=5000,
406
661
  )
407
662
  except Exception as e:
408
663
  raise RuntimeError(
@@ -419,8 +674,17 @@ async def _snapshot_via_api_async(
419
674
  raw_options["screenshot"] = options.screenshot.model_dump()
420
675
  else:
421
676
  raw_options["screenshot"] = options.screenshot
677
+ # Important: also pass limit/filter to extension to keep raw_elements payload bounded.
678
+ # Without this, large pages (e.g. Amazon) can exceed gateway request size limits (HTTP 413).
679
+ if options.limit != 50:
680
+ raw_options["limit"] = options.limit
681
+ if options.filter is not None:
682
+ raw_options["filter"] = (
683
+ options.filter.model_dump() if hasattr(options.filter, "model_dump") else options.filter
684
+ )
422
685
 
423
- raw_result = await browser.page.evaluate(
686
+ raw_result = await _page_evaluate_with_nav_retry(
687
+ browser.page,
424
688
  """
425
689
  (options) => {
426
690
  return window.sentience.snapshot(options);
@@ -466,7 +730,7 @@ async def _snapshot_via_api_async(
466
730
  )
467
731
 
468
732
  headers = {
469
- "Authorization": f"Bearer {browser.api_key}",
733
+ "Authorization": f"Bearer {api_key}",
470
734
  "Content-Type": "application/json",
471
735
  }
472
736
 
@@ -476,7 +740,7 @@ async def _snapshot_via_api_async(
476
740
 
477
741
  async with httpx.AsyncClient(timeout=30.0) as client:
478
742
  response = await client.post(
479
- f"{browser.api_url}/v1/snapshot",
743
+ f"{api_url}/v1/snapshot",
480
744
  content=payload_json,
481
745
  headers=headers,
482
746
  )
@@ -502,11 +766,15 @@ async def _snapshot_via_api_async(
502
766
  "error": api_result.get("error"),
503
767
  }
504
768
 
769
+ # Create snapshot object
770
+ snapshot_obj = Snapshot(**snapshot_data)
771
+
505
772
  # Show visual overlay if requested
506
773
  if options.show_overlay:
507
774
  elements = api_result.get("elements", [])
508
775
  if elements:
509
- await browser.page.evaluate(
776
+ await _page_evaluate_with_nav_retry(
777
+ browser.page,
510
778
  """
511
779
  (elements) => {
512
780
  if (window.sentience && window.sentience.showOverlay) {
@@ -517,7 +785,30 @@ async def _snapshot_via_api_async(
517
785
  elements,
518
786
  )
519
787
 
520
- return Snapshot(**snapshot_data)
788
+ # Show grid overlay if requested
789
+ if options.show_grid:
790
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
791
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
792
+ if grids:
793
+ grid_dicts = [grid.model_dump() for grid in grids]
794
+ # Pass grid_id as targetGridId to highlight it in red
795
+ target_grid_id = options.grid_id if options.grid_id is not None else None
796
+ await _page_evaluate_with_nav_retry(
797
+ browser.page,
798
+ """
799
+ (args) => {
800
+ const [grids, targetGridId] = args;
801
+ if (window.sentience && window.sentience.showGrid) {
802
+ window.sentience.showGrid(grids, targetGridId);
803
+ } else {
804
+ console.warn('[SDK] showGrid not available in extension');
805
+ }
806
+ }
807
+ """,
808
+ [grid_dicts, target_grid_id],
809
+ )
810
+
811
+ return snapshot_obj
521
812
  except ImportError:
522
813
  # Fallback to requests if httpx not available (shouldn't happen in async context)
523
814
  raise RuntimeError(