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,393 @@
1
+ """
2
+ CDP Backend implementation for browser-use integration.
3
+
4
+ This module provides CDPBackendV0, which implements BrowserBackend protocol
5
+ using Chrome DevTools Protocol (CDP) commands.
6
+
7
+ Usage with browser-use:
8
+ from browser_use import BrowserSession
9
+ from sentience.backends import CDPBackendV0
10
+ from sentience.backends.browser_use_adapter import BrowserUseAdapter
11
+
12
+ session = BrowserSession(...)
13
+ await session.start()
14
+
15
+ adapter = BrowserUseAdapter(session)
16
+ backend = await adapter.create_backend()
17
+
18
+ # Now use backend for Sentience operations
19
+ viewport = await backend.refresh_page_info()
20
+ await backend.mouse_click(100, 200)
21
+ """
22
+
23
+ import asyncio
24
+ import base64
25
+ import time
26
+ from typing import Any, Literal, Protocol, runtime_checkable
27
+
28
+ from .protocol import BrowserBackend, LayoutMetrics, ViewportInfo
29
+
30
+
31
+ @runtime_checkable
32
+ class CDPTransport(Protocol):
33
+ """
34
+ Protocol for CDP transport layer.
35
+
36
+ This abstracts the actual CDP communication, allowing different
37
+ implementations (browser-use, Playwright CDP, raw WebSocket).
38
+ """
39
+
40
+ async def send(self, method: str, params: dict | None = None) -> dict:
41
+ """
42
+ Send a CDP command and return the result.
43
+
44
+ Args:
45
+ method: CDP method name, e.g., "Runtime.evaluate"
46
+ params: Method parameters
47
+
48
+ Returns:
49
+ CDP response dict
50
+ """
51
+ ...
52
+
53
+
54
+ class CDPBackendV0:
55
+ """
56
+ CDP-based implementation of BrowserBackend.
57
+
58
+ This backend uses CDP commands to interact with the browser,
59
+ making it compatible with browser-use's CDP client.
60
+ """
61
+
62
+ def __init__(self, transport: CDPTransport) -> None:
63
+ """
64
+ Initialize CDP backend.
65
+
66
+ Args:
67
+ transport: CDP transport for sending commands
68
+ """
69
+ self._transport = transport
70
+ self._cached_viewport: ViewportInfo | None = None
71
+ self._execution_context_id: int | None = None
72
+
73
+ async def _get_execution_context(self) -> int:
74
+ """Get or create execution context ID for Runtime.callFunctionOn."""
75
+ if self._execution_context_id is not None:
76
+ return self._execution_context_id
77
+
78
+ # Enable Runtime domain if not already enabled
79
+ try:
80
+ await self._transport.send("Runtime.enable")
81
+ except Exception:
82
+ pass # May already be enabled
83
+
84
+ # Get the main frame's execution context
85
+ result = await self._transport.send(
86
+ "Runtime.evaluate",
87
+ {
88
+ "expression": "1",
89
+ "returnByValue": True,
90
+ },
91
+ )
92
+
93
+ # Extract context ID from the result
94
+ if "executionContextId" in result:
95
+ self._execution_context_id = result["executionContextId"]
96
+ else:
97
+ # Fallback: use context ID 1 (main frame)
98
+ self._execution_context_id = 1
99
+
100
+ return self._execution_context_id
101
+
102
+ async def refresh_page_info(self) -> ViewportInfo:
103
+ """Cache viewport + scroll offsets; cheap & safe to call often."""
104
+ result = await self.eval(
105
+ """(() => ({
106
+ width: window.innerWidth,
107
+ height: window.innerHeight,
108
+ scroll_x: window.scrollX,
109
+ scroll_y: window.scrollY,
110
+ content_width: document.documentElement.scrollWidth,
111
+ content_height: document.documentElement.scrollHeight
112
+ }))()"""
113
+ )
114
+
115
+ self._cached_viewport = ViewportInfo(
116
+ width=result.get("width", 0),
117
+ height=result.get("height", 0),
118
+ scroll_x=result.get("scroll_x", 0),
119
+ scroll_y=result.get("scroll_y", 0),
120
+ content_width=result.get("content_width"),
121
+ content_height=result.get("content_height"),
122
+ )
123
+ return self._cached_viewport
124
+
125
+ async def eval(self, expression: str) -> Any:
126
+ """Evaluate JavaScript expression using Runtime.evaluate."""
127
+ result = await self._transport.send(
128
+ "Runtime.evaluate",
129
+ {
130
+ "expression": expression,
131
+ "returnByValue": True,
132
+ "awaitPromise": True,
133
+ },
134
+ )
135
+
136
+ # Check for exceptions
137
+ if "exceptionDetails" in result:
138
+ exc = result["exceptionDetails"]
139
+ text = exc.get("text", "Unknown error")
140
+ raise RuntimeError(f"JavaScript evaluation failed: {text}")
141
+
142
+ # Extract value from result
143
+ if "result" in result:
144
+ res = result["result"]
145
+ if res.get("type") == "undefined":
146
+ return None
147
+ return res.get("value")
148
+
149
+ return None
150
+
151
+ async def call(
152
+ self,
153
+ function_declaration: str,
154
+ args: list[Any] | None = None,
155
+ ) -> Any:
156
+ """Call JavaScript function using Runtime.callFunctionOn."""
157
+ # Build call arguments
158
+ call_args = []
159
+ if args:
160
+ for arg in args:
161
+ if arg is None:
162
+ call_args.append({"value": None})
163
+ elif isinstance(arg, bool):
164
+ call_args.append({"value": arg})
165
+ elif isinstance(arg, (int, float)):
166
+ call_args.append({"value": arg})
167
+ elif isinstance(arg, str):
168
+ call_args.append({"value": arg})
169
+ elif isinstance(arg, dict):
170
+ call_args.append({"value": arg})
171
+ elif isinstance(arg, list):
172
+ call_args.append({"value": arg})
173
+ else:
174
+ # Serialize complex objects to JSON
175
+ call_args.append({"value": str(arg)})
176
+
177
+ # We need an object ID to call function on
178
+ # Use globalThis (window) as the target
179
+ global_result = await self._transport.send(
180
+ "Runtime.evaluate",
181
+ {
182
+ "expression": "globalThis",
183
+ "returnByValue": False,
184
+ },
185
+ )
186
+
187
+ object_id = global_result.get("result", {}).get("objectId")
188
+ if not object_id:
189
+ # Fallback: evaluate the function directly
190
+ if args:
191
+ args_json = ", ".join(repr(a) if isinstance(a, str) else str(a) for a in args)
192
+ expression = f"({function_declaration})({args_json})"
193
+ else:
194
+ expression = f"({function_declaration})()"
195
+ return await self.eval(expression)
196
+
197
+ result = await self._transport.send(
198
+ "Runtime.callFunctionOn",
199
+ {
200
+ "functionDeclaration": function_declaration,
201
+ "objectId": object_id,
202
+ "arguments": call_args,
203
+ "returnByValue": True,
204
+ "awaitPromise": True,
205
+ },
206
+ )
207
+
208
+ # Check for exceptions
209
+ if "exceptionDetails" in result:
210
+ exc = result["exceptionDetails"]
211
+ text = exc.get("text", "Unknown error")
212
+ raise RuntimeError(f"JavaScript call failed: {text}")
213
+
214
+ # Extract value from result
215
+ if "result" in result:
216
+ res = result["result"]
217
+ if res.get("type") == "undefined":
218
+ return None
219
+ return res.get("value")
220
+
221
+ return None
222
+
223
+ async def get_layout_metrics(self) -> LayoutMetrics:
224
+ """Get page layout metrics using Page.getLayoutMetrics."""
225
+ result = await self._transport.send("Page.getLayoutMetrics")
226
+
227
+ # Extract metrics from result
228
+ layout_viewport = result.get("layoutViewport", {})
229
+ content_size = result.get("contentSize", {})
230
+ visual_viewport = result.get("visualViewport", {})
231
+
232
+ return LayoutMetrics(
233
+ viewport_x=visual_viewport.get("pageX", 0),
234
+ viewport_y=visual_viewport.get("pageY", 0),
235
+ viewport_width=visual_viewport.get(
236
+ "clientWidth", layout_viewport.get("clientWidth", 0)
237
+ ),
238
+ viewport_height=visual_viewport.get(
239
+ "clientHeight", layout_viewport.get("clientHeight", 0)
240
+ ),
241
+ content_width=content_size.get("width", 0),
242
+ content_height=content_size.get("height", 0),
243
+ device_scale_factor=visual_viewport.get("scale", 1.0),
244
+ )
245
+
246
+ async def screenshot_png(self) -> bytes:
247
+ """Capture viewport screenshot as PNG bytes."""
248
+ result = await self._transport.send(
249
+ "Page.captureScreenshot",
250
+ {
251
+ "format": "png",
252
+ "captureBeyondViewport": False,
253
+ },
254
+ )
255
+
256
+ data = result.get("data", "")
257
+ return base64.b64decode(data)
258
+
259
+ async def mouse_move(self, x: float, y: float) -> None:
260
+ """Move mouse to viewport coordinates."""
261
+ await self._transport.send(
262
+ "Input.dispatchMouseEvent",
263
+ {
264
+ "type": "mouseMoved",
265
+ "x": x,
266
+ "y": y,
267
+ },
268
+ )
269
+
270
+ async def mouse_click(
271
+ self,
272
+ x: float,
273
+ y: float,
274
+ button: Literal["left", "right", "middle"] = "left",
275
+ click_count: int = 1,
276
+ ) -> None:
277
+ """Click at viewport coordinates."""
278
+ # Mouse down
279
+ await self._transport.send(
280
+ "Input.dispatchMouseEvent",
281
+ {
282
+ "type": "mousePressed",
283
+ "x": x,
284
+ "y": y,
285
+ "button": button,
286
+ "clickCount": click_count,
287
+ },
288
+ )
289
+
290
+ # Small delay between press and release
291
+ await asyncio.sleep(0.05)
292
+
293
+ # Mouse up
294
+ await self._transport.send(
295
+ "Input.dispatchMouseEvent",
296
+ {
297
+ "type": "mouseReleased",
298
+ "x": x,
299
+ "y": y,
300
+ "button": button,
301
+ "clickCount": click_count,
302
+ },
303
+ )
304
+
305
+ async def wheel(
306
+ self,
307
+ delta_y: float,
308
+ x: float | None = None,
309
+ y: float | None = None,
310
+ ) -> None:
311
+ """Scroll using mouse wheel."""
312
+ # Get viewport center if coordinates not provided
313
+ if x is None or y is None:
314
+ if self._cached_viewport is None:
315
+ await self.refresh_page_info()
316
+ assert self._cached_viewport is not None
317
+ x = x if x is not None else self._cached_viewport.width / 2
318
+ y = y if y is not None else self._cached_viewport.height / 2
319
+
320
+ await self._transport.send(
321
+ "Input.dispatchMouseEvent",
322
+ {
323
+ "type": "mouseWheel",
324
+ "x": x,
325
+ "y": y,
326
+ "deltaX": 0,
327
+ "deltaY": delta_y,
328
+ },
329
+ )
330
+
331
+ async def type_text(self, text: str) -> None:
332
+ """Type text using keyboard input."""
333
+ for char in text:
334
+ # Key down
335
+ await self._transport.send(
336
+ "Input.dispatchKeyEvent",
337
+ {
338
+ "type": "keyDown",
339
+ "text": char,
340
+ },
341
+ )
342
+
343
+ # Char event (for text input)
344
+ await self._transport.send(
345
+ "Input.dispatchKeyEvent",
346
+ {
347
+ "type": "char",
348
+ "text": char,
349
+ },
350
+ )
351
+
352
+ # Key up
353
+ await self._transport.send(
354
+ "Input.dispatchKeyEvent",
355
+ {
356
+ "type": "keyUp",
357
+ "text": char,
358
+ },
359
+ )
360
+
361
+ # Small delay between characters
362
+ await asyncio.sleep(0.01)
363
+
364
+ async def wait_ready_state(
365
+ self,
366
+ state: Literal["interactive", "complete"] = "interactive",
367
+ timeout_ms: int = 15000,
368
+ ) -> None:
369
+ """Wait for document.readyState using polling."""
370
+ start = time.monotonic()
371
+ timeout_sec = timeout_ms / 1000.0
372
+
373
+ # Map state to acceptable states
374
+ acceptable_states = {"complete"} if state == "complete" else {"interactive", "complete"}
375
+
376
+ while True:
377
+ elapsed = time.monotonic() - start
378
+ if elapsed >= timeout_sec:
379
+ raise TimeoutError(
380
+ f"Timed out waiting for document.readyState='{state}' " f"after {timeout_ms}ms"
381
+ )
382
+
383
+ current_state = await self.eval("document.readyState")
384
+ if current_state in acceptable_states:
385
+ return
386
+
387
+ # Poll every 100ms
388
+ await asyncio.sleep(0.1)
389
+
390
+ async def get_url(self) -> str:
391
+ """Get current page URL."""
392
+ result = await self.eval("window.location.href")
393
+ return result if result else ""
@@ -0,0 +1,211 @@
1
+ """
2
+ Custom exceptions for Sentience backends.
3
+
4
+ These exceptions provide clear, actionable error messages when things go wrong
5
+ during browser-use integration or backend operations.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+
12
+ class SentienceBackendError(Exception):
13
+ """Base exception for all Sentience backend errors."""
14
+
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class ExtensionDiagnostics:
20
+ """Diagnostics collected when extension loading fails."""
21
+
22
+ sentience_defined: bool = False
23
+ sentience_snapshot: bool = False
24
+ url: str = ""
25
+ error: str | None = None
26
+
27
+ @classmethod
28
+ def from_dict(cls, data: dict[str, Any]) -> "ExtensionDiagnostics":
29
+ """Create from diagnostic dict returned by browser eval."""
30
+ return cls(
31
+ sentience_defined=data.get("sentience_defined", False),
32
+ sentience_snapshot=data.get("sentience_snapshot", False),
33
+ url=data.get("url", ""),
34
+ error=data.get("error"),
35
+ )
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ """Convert to dict for serialization."""
39
+ return {
40
+ "sentience_defined": self.sentience_defined,
41
+ "sentience_snapshot": self.sentience_snapshot,
42
+ "url": self.url,
43
+ "error": self.error,
44
+ }
45
+
46
+
47
+ class ExtensionNotLoadedError(SentienceBackendError):
48
+ """
49
+ Raised when the Sentience extension is not loaded in the browser.
50
+
51
+ This typically means:
52
+ 1. Browser was launched without --load-extension flag
53
+ 2. Extension path is incorrect
54
+ 3. Extension failed to initialize
55
+
56
+ Example fix for browser-use:
57
+ from sentience import get_extension_dir
58
+ from browser_use import BrowserSession, BrowserProfile
59
+
60
+ profile = BrowserProfile(
61
+ args=[f"--load-extension={get_extension_dir()}"],
62
+ )
63
+ session = BrowserSession(browser_profile=profile)
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ message: str,
69
+ timeout_ms: int | None = None,
70
+ diagnostics: ExtensionDiagnostics | None = None,
71
+ ) -> None:
72
+ self.timeout_ms = timeout_ms
73
+ self.diagnostics = diagnostics
74
+ super().__init__(message)
75
+
76
+ @classmethod
77
+ def from_timeout(
78
+ cls,
79
+ timeout_ms: int,
80
+ diagnostics: ExtensionDiagnostics | None = None,
81
+ ) -> "ExtensionNotLoadedError":
82
+ """Create error from timeout during extension wait."""
83
+ diag_info = ""
84
+ if diagnostics:
85
+ if diagnostics.error:
86
+ diag_info = f"\n Error: {diagnostics.error}"
87
+ else:
88
+ diag_info = (
89
+ f"\n window.sentience defined: {diagnostics.sentience_defined}"
90
+ f"\n window.sentience.snapshot available: {diagnostics.sentience_snapshot}"
91
+ f"\n Page URL: {diagnostics.url}"
92
+ )
93
+
94
+ message = (
95
+ f"Sentience extension not loaded after {timeout_ms}ms.{diag_info}\n\n"
96
+ "To fix this, ensure the extension is loaded when launching the browser:\n\n"
97
+ " from sentience import get_extension_dir\n"
98
+ " from browser_use import BrowserSession, BrowserProfile\n\n"
99
+ " profile = BrowserProfile(\n"
100
+ f' args=[f"--load-extension={{get_extension_dir()}}"],\n'
101
+ " )\n"
102
+ " session = BrowserSession(browser_profile=profile)\n"
103
+ )
104
+ return cls(message, timeout_ms=timeout_ms, diagnostics=diagnostics)
105
+
106
+
107
+ class ExtensionInjectionError(SentienceBackendError):
108
+ """
109
+ Raised when window.sentience API is not available on the page.
110
+
111
+ This can happen when:
112
+ 1. Page loaded before extension could inject
113
+ 2. Page has Content Security Policy blocking extension
114
+ 3. Extension crashed or was disabled
115
+
116
+ Call snapshot() with a longer timeout or wait for page load.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ message: str,
122
+ url: str | None = None,
123
+ ) -> None:
124
+ self.url = url
125
+ super().__init__(message)
126
+
127
+ @classmethod
128
+ def from_page(cls, url: str) -> "ExtensionInjectionError":
129
+ """Create error for a specific page."""
130
+ message = (
131
+ f"window.sentience API not available on page: {url}\n\n"
132
+ "Possible causes:\n"
133
+ " 1. Page loaded before extension could inject (try increasing timeout)\n"
134
+ " 2. Page has Content Security Policy blocking the extension\n"
135
+ " 3. Extension was disabled or crashed\n\n"
136
+ "Try:\n"
137
+ " snap = await snapshot(backend, options=SnapshotOptions(timeout_ms=10000))"
138
+ )
139
+ return cls(message, url=url)
140
+
141
+
142
+ class BackendEvalError(SentienceBackendError):
143
+ """
144
+ Raised when JavaScript evaluation fails in the browser.
145
+
146
+ This wraps underlying CDP or Playwright errors with context.
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ message: str,
152
+ expression: str | None = None,
153
+ original_error: Exception | None = None,
154
+ ) -> None:
155
+ self.expression = expression
156
+ self.original_error = original_error
157
+ super().__init__(message)
158
+
159
+
160
+ class SnapshotError(SentienceBackendError):
161
+ """
162
+ Raised when taking a snapshot fails.
163
+
164
+ This can happen when:
165
+ 1. Extension returned null or invalid data
166
+ 2. Page is in an invalid state
167
+ 3. Extension threw an error
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ message: str,
173
+ url: str | None = None,
174
+ raw_result: Any = None,
175
+ ) -> None:
176
+ self.url = url
177
+ self.raw_result = raw_result
178
+ super().__init__(message)
179
+
180
+ @classmethod
181
+ def from_null_result(cls, url: str | None = None) -> "SnapshotError":
182
+ """Create error for null snapshot result."""
183
+ message = (
184
+ "window.sentience.snapshot() returned null.\n\n"
185
+ "Possible causes:\n"
186
+ " 1. Extension is not properly initialized\n"
187
+ " 2. Page DOM is in an invalid state\n"
188
+ " 3. Extension encountered an internal error\n\n"
189
+ "Try refreshing the page and taking a new snapshot."
190
+ )
191
+ if url:
192
+ message = f"{message}\n Page URL: {url}"
193
+ return cls(message, url=url, raw_result=None)
194
+
195
+
196
+ class ActionError(SentienceBackendError):
197
+ """
198
+ Raised when a browser action (click, type, scroll) fails.
199
+ """
200
+
201
+ def __init__(
202
+ self,
203
+ action: str,
204
+ message: str,
205
+ coordinates: tuple[float, float] | None = None,
206
+ original_error: Exception | None = None,
207
+ ) -> None:
208
+ self.action = action
209
+ self.coordinates = coordinates
210
+ self.original_error = original_error
211
+ super().__init__(f"{action} failed: {message}")