sentienceapi 0.95.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 (82) hide show
  1. sentience/__init__.py +253 -0
  2. sentience/_extension_loader.py +195 -0
  3. sentience/action_executor.py +215 -0
  4. sentience/actions.py +1020 -0
  5. sentience/agent.py +1181 -0
  6. sentience/agent_config.py +46 -0
  7. sentience/agent_runtime.py +424 -0
  8. sentience/asserts/__init__.py +70 -0
  9. sentience/asserts/expect.py +621 -0
  10. sentience/asserts/query.py +383 -0
  11. sentience/async_api.py +108 -0
  12. sentience/backends/__init__.py +137 -0
  13. sentience/backends/actions.py +343 -0
  14. sentience/backends/browser_use_adapter.py +241 -0
  15. sentience/backends/cdp_backend.py +393 -0
  16. sentience/backends/exceptions.py +211 -0
  17. sentience/backends/playwright_backend.py +194 -0
  18. sentience/backends/protocol.py +216 -0
  19. sentience/backends/sentience_context.py +469 -0
  20. sentience/backends/snapshot.py +427 -0
  21. sentience/base_agent.py +196 -0
  22. sentience/browser.py +1215 -0
  23. sentience/browser_evaluator.py +299 -0
  24. sentience/canonicalization.py +207 -0
  25. sentience/cli.py +130 -0
  26. sentience/cloud_tracing.py +807 -0
  27. sentience/constants.py +6 -0
  28. sentience/conversational_agent.py +543 -0
  29. sentience/element_filter.py +136 -0
  30. sentience/expect.py +188 -0
  31. sentience/extension/background.js +104 -0
  32. sentience/extension/content.js +161 -0
  33. sentience/extension/injected_api.js +914 -0
  34. sentience/extension/manifest.json +36 -0
  35. sentience/extension/pkg/sentience_core.d.ts +51 -0
  36. sentience/extension/pkg/sentience_core.js +323 -0
  37. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  38. sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
  39. sentience/extension/release.json +115 -0
  40. sentience/formatting.py +15 -0
  41. sentience/generator.py +202 -0
  42. sentience/inspector.py +367 -0
  43. sentience/llm_interaction_handler.py +191 -0
  44. sentience/llm_provider.py +875 -0
  45. sentience/llm_provider_utils.py +120 -0
  46. sentience/llm_response_builder.py +153 -0
  47. sentience/models.py +846 -0
  48. sentience/ordinal.py +280 -0
  49. sentience/overlay.py +222 -0
  50. sentience/protocols.py +228 -0
  51. sentience/query.py +303 -0
  52. sentience/read.py +188 -0
  53. sentience/recorder.py +589 -0
  54. sentience/schemas/trace_v1.json +335 -0
  55. sentience/screenshot.py +100 -0
  56. sentience/sentience_methods.py +86 -0
  57. sentience/snapshot.py +706 -0
  58. sentience/snapshot_diff.py +126 -0
  59. sentience/text_search.py +262 -0
  60. sentience/trace_event_builder.py +148 -0
  61. sentience/trace_file_manager.py +197 -0
  62. sentience/trace_indexing/__init__.py +27 -0
  63. sentience/trace_indexing/index_schema.py +199 -0
  64. sentience/trace_indexing/indexer.py +414 -0
  65. sentience/tracer_factory.py +322 -0
  66. sentience/tracing.py +449 -0
  67. sentience/utils/__init__.py +40 -0
  68. sentience/utils/browser.py +46 -0
  69. sentience/utils/element.py +257 -0
  70. sentience/utils/formatting.py +59 -0
  71. sentience/utils.py +296 -0
  72. sentience/verification.py +380 -0
  73. sentience/visual_agent.py +2058 -0
  74. sentience/wait.py +139 -0
  75. sentienceapi-0.95.0.dist-info/METADATA +984 -0
  76. sentienceapi-0.95.0.dist-info/RECORD +82 -0
  77. sentienceapi-0.95.0.dist-info/WHEEL +5 -0
  78. sentienceapi-0.95.0.dist-info/entry_points.txt +2 -0
  79. sentienceapi-0.95.0.dist-info/licenses/LICENSE +24 -0
  80. sentienceapi-0.95.0.dist-info/licenses/LICENSE-APACHE +201 -0
  81. sentienceapi-0.95.0.dist-info/licenses/LICENSE-MIT +21 -0
  82. sentienceapi-0.95.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,469 @@
1
+ """
2
+ SentienceContext: Token-Slasher Context Middleware for browser-use.
3
+
4
+ This module provides a compact, ranked DOM context block for browser-use agents,
5
+ reducing tokens and improving reliability by using Sentience snapshots.
6
+
7
+ Example usage:
8
+ from browser_use import Agent
9
+ from sentience.backends import SentienceContext
10
+
11
+ ctx = SentienceContext(show_overlay=True)
12
+ state = await ctx.build(agent.browser_session, goal="Click the first Show HN post")
13
+ if state:
14
+ agent.add_context(state.prompt_block) # or however browser-use injects state
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import logging
21
+ import re
22
+ from dataclasses import dataclass
23
+ from typing import TYPE_CHECKING, Any
24
+ from urllib.parse import urlparse
25
+
26
+ from ..constants import SENTIENCE_API_URL
27
+
28
+ if TYPE_CHECKING:
29
+ from ..models import Element, Snapshot
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ @dataclass
35
+ class SentienceContextState:
36
+ """Sentience context state with snapshot and formatted prompt block."""
37
+
38
+ url: str
39
+ snapshot: Snapshot
40
+ prompt_block: str
41
+
42
+
43
+ @dataclass
44
+ class TopElementSelector:
45
+ """
46
+ Configuration for element selection strategy.
47
+
48
+ The selector uses a 3-way merge to pick elements for the LLM context:
49
+ 1. Top N by importance score (most actionable elements)
50
+ 2. Top N from dominant group (for ordinal tasks like "click 3rd item")
51
+ 3. Top N by position (elements at top of page, lowest doc_y)
52
+
53
+ Elements are deduplicated across all three sources.
54
+ """
55
+
56
+ by_importance: int = 60
57
+ """Number of top elements to select by importance score (descending)."""
58
+
59
+ from_dominant_group: int = 15
60
+ """Number of top elements to select from the dominant group (for ordinal tasks)."""
61
+
62
+ by_position: int = 10
63
+ """Number of top elements to select by position (lowest doc_y = top of page)."""
64
+
65
+
66
+ class SentienceContext:
67
+ """
68
+ Token-Slasher Context Middleware for browser-use.
69
+
70
+ Creates a compact, ranked DOM context block using Sentience snapshots,
71
+ reducing tokens and improving reliability for LLM-based browser agents.
72
+
73
+ Example:
74
+ from browser_use import Agent
75
+ from sentience.backends import SentienceContext
76
+
77
+ ctx = SentienceContext(show_overlay=True)
78
+ state = await ctx.build(agent.browser_session, goal="Click the first Show HN post")
79
+ if state:
80
+ agent.add_context(state.prompt_block)
81
+ """
82
+
83
+ # Sentience API endpoint
84
+ API_URL = SENTIENCE_API_URL
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ sentience_api_key: str | None = None,
90
+ use_api: bool | None = None,
91
+ max_elements: int = 60,
92
+ show_overlay: bool = False,
93
+ top_element_selector: TopElementSelector | None = None,
94
+ ) -> None:
95
+ """
96
+ Initialize SentienceContext.
97
+
98
+ Args:
99
+ sentience_api_key: Sentience API key for gateway mode
100
+ use_api: Force API vs extension mode (auto-detected if None)
101
+ max_elements: Maximum elements to fetch from snapshot
102
+ show_overlay: Show visual overlay highlighting elements in browser
103
+ top_element_selector: Configuration for element selection strategy
104
+ """
105
+ self._api_key = sentience_api_key
106
+ self._use_api = use_api
107
+ self._max_elements = max_elements
108
+ self._show_overlay = show_overlay
109
+ self._selector = top_element_selector or TopElementSelector()
110
+
111
+ async def build(
112
+ self,
113
+ browser_session: Any,
114
+ *,
115
+ goal: str | None = None,
116
+ wait_for_extension_ms: int = 5000,
117
+ retries: int = 2,
118
+ retry_delay_s: float = 1.0,
119
+ ) -> SentienceContextState | None:
120
+ """
121
+ Build context state from browser session.
122
+
123
+ Takes a snapshot using the Sentience extension and formats it for LLM consumption.
124
+ Returns None if snapshot fails (extension not loaded, timeout, etc.).
125
+
126
+ Args:
127
+ browser_session: Browser-use BrowserSession instance
128
+ goal: Optional goal/task description (passed to gateway for reranking)
129
+ wait_for_extension_ms: Maximum time to wait for extension injection
130
+ retries: Number of retry attempts on snapshot failure
131
+ retry_delay_s: Delay between retries in seconds
132
+
133
+ Returns:
134
+ SentienceContextState with snapshot and formatted prompt, or None if failed
135
+ """
136
+ try:
137
+ # Import here to avoid requiring sentience as a hard dependency
138
+ from ..models import SnapshotOptions
139
+ from .browser_use_adapter import BrowserUseAdapter
140
+ from .snapshot import snapshot
141
+
142
+ # Create adapter and backend
143
+ adapter = BrowserUseAdapter(browser_session)
144
+ backend = await adapter.create_backend()
145
+
146
+ # Wait for extension to inject (poll until ready or timeout)
147
+ await self._wait_for_extension(backend, timeout_ms=wait_for_extension_ms)
148
+
149
+ # Build snapshot options
150
+ options = SnapshotOptions(
151
+ limit=self._max_elements,
152
+ show_overlay=self._show_overlay,
153
+ goal=goal,
154
+ )
155
+
156
+ # Set API options
157
+ if self._api_key:
158
+ options.sentience_api_key = self._api_key
159
+ if self._use_api is not None:
160
+ options.use_api = self._use_api
161
+ elif self._api_key:
162
+ options.use_api = True
163
+
164
+ # Take snapshot with retry logic
165
+ snap = None
166
+ last_error: Exception | None = None
167
+
168
+ for attempt in range(retries):
169
+ try:
170
+ snap = await snapshot(backend, options=options)
171
+ break # Success
172
+ except Exception as e:
173
+ last_error = e
174
+ if attempt < retries - 1:
175
+ logger.debug(
176
+ "Sentience snapshot attempt %d failed: %s, retrying...",
177
+ attempt + 1,
178
+ e,
179
+ )
180
+ await asyncio.sleep(retry_delay_s)
181
+ else:
182
+ logger.warning(
183
+ "Sentience snapshot failed after %d attempts: %s",
184
+ retries,
185
+ last_error,
186
+ )
187
+ return None
188
+
189
+ if snap is None:
190
+ logger.warning("Sentience snapshot returned None")
191
+ return None
192
+
193
+ # Get URL from snapshot
194
+ url = snap.url or ""
195
+
196
+ # Format for LLM
197
+ formatted = self._format_snapshot_for_llm(snap)
198
+
199
+ # Build prompt block
200
+ prompt = (
201
+ "Elements: ID|role|text|imp|is_primary|docYq|ord|DG|href\n"
202
+ "Rules: ordinal→DG=1 then ord asc; otherwise imp desc. "
203
+ "Use click(ID)/input_text(ID,...).\n"
204
+ f"{formatted}"
205
+ )
206
+
207
+ logger.info(
208
+ "SentienceContext snapshot: %d elements URL=%s",
209
+ len(snap.elements),
210
+ url,
211
+ )
212
+
213
+ return SentienceContextState(url=url, snapshot=snap, prompt_block=prompt)
214
+
215
+ except ImportError as e:
216
+ logger.warning("Sentience SDK not available: %s", e)
217
+ return None
218
+ except Exception as e:
219
+ logger.warning("Sentience snapshot skipped: %s", e)
220
+ return None
221
+
222
+ def _format_snapshot_for_llm(self, snapshot: Snapshot) -> str:
223
+ """
224
+ Format Sentience snapshot for LLM consumption.
225
+
226
+ Creates an ultra-compact inventory of interactive elements optimized
227
+ for minimal token usage. Uses 3-way selection: by importance,
228
+ from dominant group, and by position.
229
+
230
+ Args:
231
+ snapshot: Sentience Snapshot object
232
+
233
+ Returns:
234
+ Formatted string with format: ID|role|text|imp|is_primary|docYq|ord|DG|href
235
+ """
236
+ # Filter to interactive elements only
237
+ interactive_roles = {
238
+ "button",
239
+ "link",
240
+ "textbox",
241
+ "searchbox",
242
+ "combobox",
243
+ "checkbox",
244
+ "radio",
245
+ "slider",
246
+ "tab",
247
+ "menuitem",
248
+ "option",
249
+ "switch",
250
+ "cell",
251
+ "a",
252
+ "input",
253
+ "select",
254
+ "textarea",
255
+ }
256
+
257
+ interactive_elements: list[Element] = []
258
+ for el in snapshot.elements:
259
+ role = (el.role or "").lower()
260
+ if role in interactive_roles:
261
+ interactive_elements.append(el)
262
+
263
+ # Sort by importance (descending) for importance-based selection
264
+ interactive_elements.sort(key=lambda el: el.importance or 0, reverse=True)
265
+
266
+ # Get top N by importance (track by ID for deduplication)
267
+ selected_ids: set[int] = set()
268
+ selected_elements: list[Element] = []
269
+
270
+ for el in interactive_elements[: self._selector.by_importance]:
271
+ if el.id not in selected_ids:
272
+ selected_ids.add(el.id)
273
+ selected_elements.append(el)
274
+
275
+ # Get top elements from dominant group (for ordinal tasks)
276
+ # Prefer in_dominant_group field (uses fuzzy matching from gateway)
277
+ dominant_group_elements = [
278
+ el for el in interactive_elements if el.in_dominant_group is True
279
+ ]
280
+
281
+ # Fallback to exact group_key match if in_dominant_group not populated
282
+ if not dominant_group_elements and snapshot.dominant_group_key:
283
+ dominant_group_elements = [
284
+ el for el in interactive_elements if el.group_key == snapshot.dominant_group_key
285
+ ]
286
+
287
+ # Sort by group_index for ordinal ordering
288
+ dominant_group_elements.sort(key=lambda el: el.group_index or 999)
289
+
290
+ for el in dominant_group_elements[: self._selector.from_dominant_group]:
291
+ if el.id not in selected_ids:
292
+ selected_ids.add(el.id)
293
+ selected_elements.append(el)
294
+
295
+ # Get top elements by position (lowest doc_y = top of page)
296
+ def get_y_position(el: Element) -> float:
297
+ if el.doc_y is not None:
298
+ return el.doc_y
299
+ if el.bbox is not None:
300
+ return el.bbox.y
301
+ return float("inf")
302
+
303
+ elements_by_position = sorted(
304
+ interactive_elements, key=lambda el: (get_y_position(el), -(el.importance or 0))
305
+ )
306
+
307
+ for el in elements_by_position[: self._selector.by_position]:
308
+ if el.id not in selected_ids:
309
+ selected_ids.add(el.id)
310
+ selected_elements.append(el)
311
+
312
+ # Compute local rank_in_group for dominant group elements
313
+ rank_in_group_map: dict[int, int] = {}
314
+ if True: # Always compute rank_in_group
315
+ # Sort dominant group elements by position for rank computation
316
+ dg_elements_for_rank = [
317
+ el for el in interactive_elements if el.in_dominant_group is True
318
+ ]
319
+ if not dg_elements_for_rank and snapshot.dominant_group_key:
320
+ dg_elements_for_rank = [
321
+ el for el in interactive_elements if el.group_key == snapshot.dominant_group_key
322
+ ]
323
+
324
+ # Sort by (doc_y, bbox.y, bbox.x, -importance)
325
+ def rank_sort_key(el: Element) -> tuple[float, float, float, float]:
326
+ doc_y = el.doc_y if el.doc_y is not None else float("inf")
327
+ bbox_y = el.bbox.y if el.bbox else float("inf")
328
+ bbox_x = el.bbox.x if el.bbox else float("inf")
329
+ neg_importance = -(el.importance or 0)
330
+ return (doc_y, bbox_y, bbox_x, neg_importance)
331
+
332
+ dg_elements_for_rank.sort(key=rank_sort_key)
333
+ for rank, el in enumerate(dg_elements_for_rank):
334
+ rank_in_group_map[el.id] = rank
335
+
336
+ # Format lines
337
+ lines: list[str] = []
338
+ for el in selected_elements:
339
+ # Get role (override to "link" if element has href)
340
+ role = el.role or ""
341
+ if el.href:
342
+ role = "link"
343
+ elif not role:
344
+ # Generic fallback for interactive elements without explicit role
345
+ role = "element"
346
+
347
+ # Get name/text (truncate aggressively, normalize whitespace)
348
+ name = el.text or ""
349
+ # Remove newlines and normalize whitespace
350
+ name = re.sub(r"\s+", " ", name.strip())
351
+ if len(name) > 30:
352
+ name = name[:27] + "..."
353
+
354
+ # Extract fields
355
+ importance = el.importance or 0
356
+ doc_y = el.doc_y or 0
357
+
358
+ # is_primary: from visual_cues.is_primary (boolean)
359
+ is_primary = False
360
+ if el.visual_cues:
361
+ is_primary = el.visual_cues.is_primary or False
362
+ is_primary_flag = "1" if is_primary else "0"
363
+
364
+ # Pre-encode fields for compactness
365
+ # docYq: bucketed doc_y (round to nearest 200 for smaller numbers)
366
+ doc_yq = int(round(doc_y / 200)) if doc_y else 0
367
+
368
+ # Determine if in dominant group
369
+ in_dg = el.in_dominant_group
370
+ if in_dg is None and snapshot.dominant_group_key:
371
+ # Fallback for older gateway versions
372
+ in_dg = el.group_key == snapshot.dominant_group_key
373
+
374
+ # ord_val: rank_in_group if in dominant group
375
+ if in_dg and el.id in rank_in_group_map:
376
+ ord_val: str | int = rank_in_group_map[el.id]
377
+ else:
378
+ ord_val = "-"
379
+
380
+ # DG: 1 if dominant group, else 0
381
+ dg_flag = "1" if in_dg else "0"
382
+
383
+ # href: short token (domain or last path segment, or blank)
384
+ href = self._compress_href(el.href)
385
+
386
+ # Ultra-compact format: ID|role|text|imp|is_primary|docYq|ord|DG|href
387
+ line = f"{el.id}|{role}|{name}|{importance}|{is_primary_flag}|{doc_yq}|{ord_val}|{dg_flag}|{href}"
388
+ lines.append(line)
389
+
390
+ logger.debug(
391
+ "Formatted %d elements (top %d by importance + top %d from dominant group + top %d by position)",
392
+ len(lines),
393
+ self._selector.by_importance,
394
+ self._selector.from_dominant_group,
395
+ self._selector.by_position,
396
+ )
397
+
398
+ return "\n".join(lines)
399
+
400
+ async def _wait_for_extension(
401
+ self,
402
+ backend: Any,
403
+ *,
404
+ timeout_ms: int = 5000,
405
+ poll_interval_ms: int = 100,
406
+ ) -> bool:
407
+ """
408
+ Wait for Sentience extension to be ready in the browser.
409
+
410
+ Polls window.sentience until it's defined or timeout is reached.
411
+
412
+ Args:
413
+ backend: Browser backend with evaluate() method
414
+ timeout_ms: Maximum time to wait in milliseconds
415
+ poll_interval_ms: Interval between polls in milliseconds
416
+
417
+ Returns:
418
+ True if extension is ready, False if timeout
419
+ """
420
+ elapsed_ms = 0
421
+ poll_interval_s = poll_interval_ms / 1000
422
+
423
+ while elapsed_ms < timeout_ms:
424
+ try:
425
+ result = await backend.evaluate("typeof window.sentience !== 'undefined'")
426
+ if result is True:
427
+ logger.debug("Sentience extension ready after %dms", elapsed_ms)
428
+ return True
429
+ except Exception:
430
+ # Extension not ready yet, continue polling
431
+ pass
432
+
433
+ await asyncio.sleep(poll_interval_s)
434
+ elapsed_ms += poll_interval_ms
435
+
436
+ logger.warning("Sentience extension not ready after %dms timeout", timeout_ms)
437
+ return False
438
+
439
+ def _compress_href(self, href: str | None) -> str:
440
+ """
441
+ Compress href into a short token for minimal tokens.
442
+
443
+ Args:
444
+ href: Full URL or None
445
+
446
+ Returns:
447
+ Short token (domain second-level or last path segment)
448
+ """
449
+ if not href:
450
+ return ""
451
+
452
+ try:
453
+ parsed = urlparse(href)
454
+ if parsed.netloc:
455
+ # Extract second-level domain (e.g., "github" from "github.com")
456
+ parts = parsed.netloc.split(".")
457
+ if len(parts) >= 2:
458
+ return parts[-2][:10]
459
+ return parsed.netloc[:10]
460
+ elif parsed.path:
461
+ # Use last path segment
462
+ segments = [s for s in parsed.path.split("/") if s]
463
+ if segments:
464
+ return segments[-1][:10]
465
+ return "item"
466
+ except Exception:
467
+ pass
468
+
469
+ return "item"