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
sentience/actions.py ADDED
@@ -0,0 +1,1020 @@
1
+ from typing import Optional
2
+
3
+ """
4
+ Actions v1 - click, type, press
5
+ """
6
+
7
+ import time
8
+
9
+ from .browser import AsyncSentienceBrowser, SentienceBrowser
10
+ from .browser_evaluator import BrowserEvaluator
11
+ from .models import ActionResult, BBox, Snapshot
12
+ from .sentience_methods import SentienceMethod
13
+ from .snapshot import snapshot, snapshot_async
14
+
15
+
16
+ def click( # noqa: C901
17
+ browser: SentienceBrowser,
18
+ element_id: int,
19
+ use_mouse: bool = True,
20
+ take_snapshot: bool = False,
21
+ ) -> ActionResult:
22
+ """
23
+ Click an element by ID using hybrid approach (mouse simulation by default)
24
+
25
+ Args:
26
+ browser: SentienceBrowser instance
27
+ element_id: Element ID from snapshot
28
+ use_mouse: If True, use Playwright's mouse.click() at element center (hybrid approach).
29
+ If False, use JS-based window.sentience.click() (legacy).
30
+ take_snapshot: Whether to take snapshot after action
31
+
32
+ Returns:
33
+ ActionResult
34
+ """
35
+ if not browser.page:
36
+ raise RuntimeError("Browser not started. Call browser.start() first.")
37
+
38
+ start_time = time.time()
39
+ url_before = browser.page.url
40
+
41
+ if use_mouse:
42
+ # Hybrid approach: Get element bbox from snapshot, calculate center, use mouse.click()
43
+ try:
44
+ snap = snapshot(browser)
45
+ element = None
46
+ for el in snap.elements:
47
+ if el.id == element_id:
48
+ element = el
49
+ break
50
+
51
+ if element:
52
+ # Calculate center of element bbox
53
+ center_x = element.bbox.x + element.bbox.width / 2
54
+ center_y = element.bbox.y + element.bbox.height / 2
55
+ # Use Playwright's native mouse click for realistic simulation
56
+ try:
57
+ browser.page.mouse.click(center_x, center_y)
58
+ success = True
59
+ except Exception:
60
+ # If navigation happens, mouse.click might fail, but that's OK
61
+ # The click still happened, just check URL change
62
+ success = True
63
+ else:
64
+ # Fallback to JS click if element not found in snapshot
65
+ try:
66
+ success = BrowserEvaluator.invoke(
67
+ browser.page, SentienceMethod.CLICK, element_id
68
+ )
69
+ except Exception:
70
+ # Navigation might have destroyed context, assume success if URL changed
71
+ success = True
72
+ except Exception:
73
+ # Fallback to JS click on error
74
+ try:
75
+ success = BrowserEvaluator.invoke(browser.page, SentienceMethod.CLICK, element_id)
76
+ except Exception:
77
+ # Navigation might have destroyed context, assume success if URL changed
78
+ success = True
79
+ else:
80
+ # Legacy JS-based click
81
+ success = BrowserEvaluator.invoke(browser.page, SentienceMethod.CLICK, element_id)
82
+
83
+ # Wait a bit for navigation/DOM updates
84
+ try:
85
+ browser.page.wait_for_timeout(500)
86
+ except Exception:
87
+ # Navigation might have happened, context destroyed
88
+ pass
89
+
90
+ duration_ms = int((time.time() - start_time) * 1000)
91
+
92
+ # Check if URL changed (handle navigation gracefully)
93
+ try:
94
+ url_after = browser.page.url
95
+ url_changed = url_before != url_after
96
+ except Exception:
97
+ # Context destroyed due to navigation - assume URL changed
98
+ url_after = url_before
99
+ url_changed = True
100
+
101
+ # Determine outcome
102
+ outcome: str | None = None
103
+ if url_changed:
104
+ outcome = "navigated"
105
+ elif success:
106
+ outcome = "dom_updated"
107
+ else:
108
+ outcome = "error"
109
+
110
+ # Optional snapshot after
111
+ snapshot_after: Snapshot | None = None
112
+ if take_snapshot:
113
+ try:
114
+ snapshot_after = snapshot(browser)
115
+ except Exception:
116
+ # Navigation might have destroyed context
117
+ pass
118
+
119
+ return ActionResult(
120
+ success=success,
121
+ duration_ms=duration_ms,
122
+ outcome=outcome,
123
+ url_changed=url_changed,
124
+ snapshot_after=snapshot_after,
125
+ error=(
126
+ None
127
+ if success
128
+ else {
129
+ "code": "click_failed",
130
+ "reason": "Element not found or not clickable",
131
+ }
132
+ ),
133
+ )
134
+
135
+
136
+ def type_text(
137
+ browser: SentienceBrowser,
138
+ element_id: int,
139
+ text: str,
140
+ take_snapshot: bool = False,
141
+ delay_ms: float = 0,
142
+ ) -> ActionResult:
143
+ """
144
+ Type text into an element (focus then input)
145
+
146
+ Args:
147
+ browser: SentienceBrowser instance
148
+ element_id: Element ID from snapshot
149
+ text: Text to type
150
+ take_snapshot: Whether to take snapshot after action
151
+ delay_ms: Delay between keystrokes in milliseconds for human-like typing (default: 0)
152
+
153
+ Returns:
154
+ ActionResult
155
+
156
+ Example:
157
+ >>> # Type instantly (default behavior)
158
+ >>> type_text(browser, element_id, "Hello World")
159
+ >>> # Type with human-like delay (~10ms between keystrokes)
160
+ >>> type_text(browser, element_id, "Hello World", delay_ms=10)
161
+ """
162
+ if not browser.page:
163
+ raise RuntimeError("Browser not started. Call browser.start() first.")
164
+
165
+ start_time = time.time()
166
+ url_before = browser.page.url
167
+
168
+ # Focus element first using extension registry
169
+ focused = browser.page.evaluate(
170
+ """
171
+ (id) => {
172
+ const el = window.sentience_registry[id];
173
+ if (el) {
174
+ el.focus();
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ """,
180
+ element_id,
181
+ )
182
+
183
+ if not focused:
184
+ return ActionResult(
185
+ success=False,
186
+ duration_ms=int((time.time() - start_time) * 1000),
187
+ outcome="error",
188
+ error={"code": "focus_failed", "reason": "Element not found"},
189
+ )
190
+
191
+ # Type using Playwright keyboard with optional delay between keystrokes
192
+ browser.page.keyboard.type(text, delay=delay_ms)
193
+
194
+ duration_ms = int((time.time() - start_time) * 1000)
195
+ url_after = browser.page.url
196
+ url_changed = url_before != url_after
197
+
198
+ outcome = "navigated" if url_changed else "dom_updated"
199
+
200
+ snapshot_after: Snapshot | None = None
201
+ if take_snapshot:
202
+ snapshot_after = snapshot(browser)
203
+
204
+ return ActionResult(
205
+ success=True,
206
+ duration_ms=duration_ms,
207
+ outcome=outcome,
208
+ url_changed=url_changed,
209
+ snapshot_after=snapshot_after,
210
+ )
211
+
212
+
213
+ def press(browser: SentienceBrowser, key: str, take_snapshot: bool = False) -> ActionResult:
214
+ """
215
+ Press a keyboard key
216
+
217
+ Args:
218
+ browser: SentienceBrowser instance
219
+ key: Key to press (e.g., "Enter", "Escape", "Tab")
220
+ take_snapshot: Whether to take snapshot after action
221
+
222
+ Returns:
223
+ ActionResult
224
+ """
225
+ if not browser.page:
226
+ raise RuntimeError("Browser not started. Call browser.start() first.")
227
+
228
+ start_time = time.time()
229
+ url_before = browser.page.url
230
+
231
+ # Press key using Playwright
232
+ browser.page.keyboard.press(key)
233
+
234
+ # Wait a bit for navigation/DOM updates
235
+ browser.page.wait_for_timeout(500)
236
+
237
+ duration_ms = int((time.time() - start_time) * 1000)
238
+ url_after = browser.page.url
239
+ url_changed = url_before != url_after
240
+
241
+ outcome = "navigated" if url_changed else "dom_updated"
242
+
243
+ snapshot_after: Snapshot | None = None
244
+ if take_snapshot:
245
+ snapshot_after = snapshot(browser)
246
+
247
+ return ActionResult(
248
+ success=True,
249
+ duration_ms=duration_ms,
250
+ outcome=outcome,
251
+ url_changed=url_changed,
252
+ snapshot_after=snapshot_after,
253
+ )
254
+
255
+
256
+ def scroll_to(
257
+ browser: SentienceBrowser,
258
+ element_id: int,
259
+ behavior: str = "smooth",
260
+ block: str = "center",
261
+ take_snapshot: bool = False,
262
+ ) -> ActionResult:
263
+ """
264
+ Scroll an element into view
265
+
266
+ Scrolls the page so that the specified element is visible in the viewport.
267
+ Uses the element registry to find the element and scrollIntoView() to scroll it.
268
+
269
+ Args:
270
+ browser: SentienceBrowser instance
271
+ element_id: Element ID from snapshot to scroll into view
272
+ behavior: Scroll behavior - 'smooth', 'instant', or 'auto' (default: 'smooth')
273
+ block: Vertical alignment - 'start', 'center', 'end', or 'nearest' (default: 'center')
274
+ take_snapshot: Whether to take snapshot after action
275
+
276
+ Returns:
277
+ ActionResult
278
+
279
+ Example:
280
+ >>> snap = snapshot(browser)
281
+ >>> button = find(snap, 'role=button[name="Submit"]')
282
+ >>> if button:
283
+ >>> # Scroll element into view with smooth animation
284
+ >>> scroll_to(browser, button.id)
285
+ >>> # Scroll instantly to top of viewport
286
+ >>> scroll_to(browser, button.id, behavior='instant', block='start')
287
+ """
288
+ if not browser.page:
289
+ raise RuntimeError("Browser not started. Call browser.start() first.")
290
+
291
+ start_time = time.time()
292
+ url_before = browser.page.url
293
+
294
+ # Scroll element into view using the element registry
295
+ scrolled = browser.page.evaluate(
296
+ """
297
+ (args) => {
298
+ const el = window.sentience_registry[args.id];
299
+ if (el && el.scrollIntoView) {
300
+ el.scrollIntoView({
301
+ behavior: args.behavior,
302
+ block: args.block,
303
+ inline: 'nearest'
304
+ });
305
+ return true;
306
+ }
307
+ return false;
308
+ }
309
+ """,
310
+ {"id": element_id, "behavior": behavior, "block": block},
311
+ )
312
+
313
+ if not scrolled:
314
+ return ActionResult(
315
+ success=False,
316
+ duration_ms=int((time.time() - start_time) * 1000),
317
+ outcome="error",
318
+ error={"code": "scroll_failed", "reason": "Element not found or not scrollable"},
319
+ )
320
+
321
+ # Wait a bit for scroll to complete (especially for smooth scrolling)
322
+ wait_time = 500 if behavior == "smooth" else 100
323
+ browser.page.wait_for_timeout(wait_time)
324
+
325
+ duration_ms = int((time.time() - start_time) * 1000)
326
+ url_after = browser.page.url
327
+ url_changed = url_before != url_after
328
+
329
+ outcome = "navigated" if url_changed else "dom_updated"
330
+
331
+ snapshot_after: Snapshot | None = None
332
+ if take_snapshot:
333
+ snapshot_after = snapshot(browser)
334
+
335
+ return ActionResult(
336
+ success=True,
337
+ duration_ms=duration_ms,
338
+ outcome=outcome,
339
+ url_changed=url_changed,
340
+ snapshot_after=snapshot_after,
341
+ )
342
+
343
+
344
+ def _highlight_rect(
345
+ browser: SentienceBrowser, rect: dict[str, float], duration_sec: float = 2.0
346
+ ) -> None:
347
+ """
348
+ Highlight a rectangle with a red border overlay
349
+
350
+ Args:
351
+ browser: SentienceBrowser instance
352
+ rect: Dictionary with x, y, width (w), height (h) keys
353
+ duration_sec: How long to show the highlight (default: 2 seconds)
354
+ """
355
+ if not browser.page:
356
+ return
357
+
358
+ # Create a unique ID for this highlight
359
+ highlight_id = f"sentience_highlight_{int(time.time() * 1000)}"
360
+
361
+ # Combine all arguments into a single object for Playwright
362
+ args = {
363
+ "rect": {
364
+ "x": rect["x"],
365
+ "y": rect["y"],
366
+ "w": rect["w"],
367
+ "h": rect["h"],
368
+ },
369
+ "highlightId": highlight_id,
370
+ "durationSec": duration_sec,
371
+ }
372
+
373
+ # Inject CSS and create overlay element
374
+ browser.page.evaluate(
375
+ """
376
+ (args) => {
377
+ const { rect, highlightId, durationSec } = args;
378
+ // Create overlay div
379
+ const overlay = document.createElement('div');
380
+ overlay.id = highlightId;
381
+ overlay.style.position = 'fixed';
382
+ overlay.style.left = `${rect.x}px`;
383
+ overlay.style.top = `${rect.y}px`;
384
+ overlay.style.width = `${rect.w}px`;
385
+ overlay.style.height = `${rect.h}px`;
386
+ overlay.style.border = '3px solid red';
387
+ overlay.style.borderRadius = '2px';
388
+ overlay.style.boxSizing = 'border-box';
389
+ overlay.style.pointerEvents = 'none';
390
+ overlay.style.zIndex = '999999';
391
+ overlay.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
392
+ overlay.style.transition = 'opacity 0.3s ease-out';
393
+
394
+ document.body.appendChild(overlay);
395
+
396
+ // Remove after duration
397
+ setTimeout(() => {
398
+ overlay.style.opacity = '0';
399
+ setTimeout(() => {
400
+ if (overlay.parentNode) {
401
+ overlay.parentNode.removeChild(overlay);
402
+ }
403
+ }, 300); // Wait for fade-out transition
404
+ }, durationSec * 1000);
405
+ }
406
+ """,
407
+ args,
408
+ )
409
+
410
+
411
+ def click_rect(
412
+ browser: SentienceBrowser,
413
+ rect: dict[str, float],
414
+ highlight: bool = True,
415
+ highlight_duration: float = 2.0,
416
+ take_snapshot: bool = False,
417
+ ) -> ActionResult:
418
+ """
419
+ Click at the center of a rectangle using Playwright's native mouse simulation.
420
+ This uses a hybrid approach: calculates center coordinates and uses mouse.click()
421
+ for realistic event simulation (triggers hover, focus, mousedown, mouseup).
422
+
423
+ Args:
424
+ browser: SentienceBrowser instance
425
+ rect: Dictionary with x, y, width (w), height (h) keys, or BBox object
426
+ highlight: Whether to show a red border highlight when clicking (default: True)
427
+ highlight_duration: How long to show the highlight in seconds (default: 2.0)
428
+ take_snapshot: Whether to take snapshot after action
429
+
430
+ Returns:
431
+ ActionResult
432
+
433
+ Example:
434
+ >>> click_rect(browser, {"x": 100, "y": 200, "w": 50, "h": 30})
435
+ >>> # Or using BBox object
436
+ >>> from sentience import BBox
437
+ >>> bbox = BBox(x=100, y=200, width=50, height=30)
438
+ >>> click_rect(browser, {"x": bbox.x, "y": bbox.y, "w": bbox.width, "h": bbox.height})
439
+ """
440
+ if not browser.page:
441
+ raise RuntimeError("Browser not started. Call browser.start() first.")
442
+
443
+ # Handle BBox object or dict
444
+ if isinstance(rect, BBox):
445
+ x = rect.x
446
+ y = rect.y
447
+ w = rect.width
448
+ h = rect.height
449
+ else:
450
+ x = rect.get("x", 0)
451
+ y = rect.get("y", 0)
452
+ w = rect.get("w") or rect.get("width", 0)
453
+ h = rect.get("h") or rect.get("height", 0)
454
+
455
+ if w <= 0 or h <= 0:
456
+ return ActionResult(
457
+ success=False,
458
+ duration_ms=0,
459
+ outcome="error",
460
+ error={
461
+ "code": "invalid_rect",
462
+ "reason": "Rectangle width and height must be positive",
463
+ },
464
+ )
465
+
466
+ start_time = time.time()
467
+ url_before = browser.page.url
468
+
469
+ # Calculate center of rectangle
470
+ center_x = x + w / 2
471
+ center_y = y + h / 2
472
+
473
+ # Show highlight before clicking (if enabled)
474
+ if highlight:
475
+ _highlight_rect(browser, {"x": x, "y": y, "w": w, "h": h}, highlight_duration)
476
+ # Small delay to ensure highlight is visible
477
+ browser.page.wait_for_timeout(50)
478
+
479
+ # Use Playwright's native mouse click for realistic simulation
480
+ # This triggers hover, focus, mousedown, mouseup sequences
481
+ try:
482
+ browser.page.mouse.click(center_x, center_y)
483
+ success = True
484
+ except Exception as e:
485
+ success = False
486
+ error_msg = str(e)
487
+
488
+ # Wait a bit for navigation/DOM updates
489
+ browser.page.wait_for_timeout(500)
490
+
491
+ duration_ms = int((time.time() - start_time) * 1000)
492
+ url_after = browser.page.url
493
+ url_changed = url_before != url_after
494
+
495
+ # Determine outcome
496
+ outcome: str | None = None
497
+ if url_changed:
498
+ outcome = "navigated"
499
+ elif success:
500
+ outcome = "dom_updated"
501
+ else:
502
+ outcome = "error"
503
+
504
+ # Optional snapshot after
505
+ snapshot_after: Snapshot | None = None
506
+ if take_snapshot:
507
+ snapshot_after = snapshot(browser)
508
+
509
+ return ActionResult(
510
+ success=success,
511
+ duration_ms=duration_ms,
512
+ outcome=outcome,
513
+ url_changed=url_changed,
514
+ snapshot_after=snapshot_after,
515
+ error=(
516
+ None
517
+ if success
518
+ else {
519
+ "code": "click_failed",
520
+ "reason": error_msg if not success else "Click failed",
521
+ }
522
+ ),
523
+ )
524
+
525
+
526
+ # ========== Async Action Functions ==========
527
+
528
+
529
+ async def click_async(
530
+ browser: AsyncSentienceBrowser,
531
+ element_id: int,
532
+ use_mouse: bool = True,
533
+ take_snapshot: bool = False,
534
+ ) -> ActionResult:
535
+ """
536
+ Click an element by ID using hybrid approach (async)
537
+
538
+ Args:
539
+ browser: AsyncSentienceBrowser instance
540
+ element_id: Element ID from snapshot
541
+ use_mouse: If True, use Playwright's mouse.click() at element center
542
+ take_snapshot: Whether to take snapshot after action
543
+
544
+ Returns:
545
+ ActionResult
546
+ """
547
+ if not browser.page:
548
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
549
+
550
+ start_time = time.time()
551
+ url_before = browser.page.url
552
+
553
+ if use_mouse:
554
+ try:
555
+ snap = await snapshot_async(browser)
556
+ element = None
557
+ for el in snap.elements:
558
+ if el.id == element_id:
559
+ element = el
560
+ break
561
+
562
+ if element:
563
+ center_x = element.bbox.x + element.bbox.width / 2
564
+ center_y = element.bbox.y + element.bbox.height / 2
565
+ try:
566
+ await browser.page.mouse.click(center_x, center_y)
567
+ success = True
568
+ except Exception:
569
+ success = True
570
+ else:
571
+ try:
572
+ success = await browser.page.evaluate(
573
+ """
574
+ (id) => {
575
+ return window.sentience.click(id);
576
+ }
577
+ """,
578
+ element_id,
579
+ )
580
+ except Exception:
581
+ success = True
582
+ except Exception:
583
+ try:
584
+ success = await browser.page.evaluate(
585
+ """
586
+ (id) => {
587
+ return window.sentience.click(id);
588
+ }
589
+ """,
590
+ element_id,
591
+ )
592
+ except Exception:
593
+ success = True
594
+ else:
595
+ success = await browser.page.evaluate(
596
+ """
597
+ (id) => {
598
+ return window.sentience.click(id);
599
+ }
600
+ """,
601
+ element_id,
602
+ )
603
+
604
+ # Wait a bit for navigation/DOM updates
605
+ try:
606
+ await browser.page.wait_for_timeout(500)
607
+ except Exception:
608
+ pass
609
+
610
+ duration_ms = int((time.time() - start_time) * 1000)
611
+
612
+ # Check if URL changed
613
+ try:
614
+ url_after = browser.page.url
615
+ url_changed = url_before != url_after
616
+ except Exception:
617
+ url_after = url_before
618
+ url_changed = True
619
+
620
+ # Determine outcome
621
+ outcome: str | None = None
622
+ if url_changed:
623
+ outcome = "navigated"
624
+ elif success:
625
+ outcome = "dom_updated"
626
+ else:
627
+ outcome = "error"
628
+
629
+ # Optional snapshot after
630
+ snapshot_after: Snapshot | None = None
631
+ if take_snapshot:
632
+ try:
633
+ snapshot_after = await snapshot_async(browser)
634
+ except Exception:
635
+ pass
636
+
637
+ return ActionResult(
638
+ success=success,
639
+ duration_ms=duration_ms,
640
+ outcome=outcome,
641
+ url_changed=url_changed,
642
+ snapshot_after=snapshot_after,
643
+ error=(
644
+ None
645
+ if success
646
+ else {
647
+ "code": "click_failed",
648
+ "reason": "Element not found or not clickable",
649
+ }
650
+ ),
651
+ )
652
+
653
+
654
+ async def type_text_async(
655
+ browser: AsyncSentienceBrowser,
656
+ element_id: int,
657
+ text: str,
658
+ take_snapshot: bool = False,
659
+ delay_ms: float = 0,
660
+ ) -> ActionResult:
661
+ """
662
+ Type text into an element (async)
663
+
664
+ Args:
665
+ browser: AsyncSentienceBrowser instance
666
+ element_id: Element ID from snapshot
667
+ text: Text to type
668
+ take_snapshot: Whether to take snapshot after action
669
+ delay_ms: Delay between keystrokes in milliseconds for human-like typing (default: 0)
670
+
671
+ Returns:
672
+ ActionResult
673
+
674
+ Example:
675
+ >>> # Type instantly (default behavior)
676
+ >>> await type_text_async(browser, element_id, "Hello World")
677
+ >>> # Type with human-like delay (~10ms between keystrokes)
678
+ >>> await type_text_async(browser, element_id, "Hello World", delay_ms=10)
679
+ """
680
+ if not browser.page:
681
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
682
+
683
+ start_time = time.time()
684
+ url_before = browser.page.url
685
+
686
+ # Focus element first
687
+ focused = await browser.page.evaluate(
688
+ """
689
+ (id) => {
690
+ const el = window.sentience_registry[id];
691
+ if (el) {
692
+ el.focus();
693
+ return true;
694
+ }
695
+ return false;
696
+ }
697
+ """,
698
+ element_id,
699
+ )
700
+
701
+ if not focused:
702
+ return ActionResult(
703
+ success=False,
704
+ duration_ms=int((time.time() - start_time) * 1000),
705
+ outcome="error",
706
+ error={"code": "focus_failed", "reason": "Element not found"},
707
+ )
708
+
709
+ # Type using Playwright keyboard with optional delay between keystrokes
710
+ await browser.page.keyboard.type(text, delay=delay_ms)
711
+
712
+ duration_ms = int((time.time() - start_time) * 1000)
713
+ url_after = browser.page.url
714
+ url_changed = url_before != url_after
715
+
716
+ outcome = "navigated" if url_changed else "dom_updated"
717
+
718
+ snapshot_after: Snapshot | None = None
719
+ if take_snapshot:
720
+ snapshot_after = await snapshot_async(browser)
721
+
722
+ return ActionResult(
723
+ success=True,
724
+ duration_ms=duration_ms,
725
+ outcome=outcome,
726
+ url_changed=url_changed,
727
+ snapshot_after=snapshot_after,
728
+ )
729
+
730
+
731
+ async def press_async(
732
+ browser: AsyncSentienceBrowser, key: str, take_snapshot: bool = False
733
+ ) -> ActionResult:
734
+ """
735
+ Press a keyboard key (async)
736
+
737
+ Args:
738
+ browser: AsyncSentienceBrowser instance
739
+ key: Key to press (e.g., "Enter", "Escape", "Tab")
740
+ take_snapshot: Whether to take snapshot after action
741
+
742
+ Returns:
743
+ ActionResult
744
+ """
745
+ if not browser.page:
746
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
747
+
748
+ start_time = time.time()
749
+ url_before = browser.page.url
750
+
751
+ # Press key using Playwright
752
+ await browser.page.keyboard.press(key)
753
+
754
+ # Wait a bit for navigation/DOM updates
755
+ await browser.page.wait_for_timeout(500)
756
+
757
+ duration_ms = int((time.time() - start_time) * 1000)
758
+ url_after = browser.page.url
759
+ url_changed = url_before != url_after
760
+
761
+ outcome = "navigated" if url_changed else "dom_updated"
762
+
763
+ snapshot_after: Snapshot | None = None
764
+ if take_snapshot:
765
+ snapshot_after = await snapshot_async(browser)
766
+
767
+ return ActionResult(
768
+ success=True,
769
+ duration_ms=duration_ms,
770
+ outcome=outcome,
771
+ url_changed=url_changed,
772
+ snapshot_after=snapshot_after,
773
+ )
774
+
775
+
776
+ async def scroll_to_async(
777
+ browser: AsyncSentienceBrowser,
778
+ element_id: int,
779
+ behavior: str = "smooth",
780
+ block: str = "center",
781
+ take_snapshot: bool = False,
782
+ ) -> ActionResult:
783
+ """
784
+ Scroll an element into view (async)
785
+
786
+ Scrolls the page so that the specified element is visible in the viewport.
787
+ Uses the element registry to find the element and scrollIntoView() to scroll it.
788
+
789
+ Args:
790
+ browser: AsyncSentienceBrowser instance
791
+ element_id: Element ID from snapshot to scroll into view
792
+ behavior: Scroll behavior - 'smooth', 'instant', or 'auto' (default: 'smooth')
793
+ block: Vertical alignment - 'start', 'center', 'end', or 'nearest' (default: 'center')
794
+ take_snapshot: Whether to take snapshot after action
795
+
796
+ Returns:
797
+ ActionResult
798
+
799
+ Example:
800
+ >>> snap = await snapshot_async(browser)
801
+ >>> button = find(snap, 'role=button[name="Submit"]')
802
+ >>> if button:
803
+ >>> # Scroll element into view with smooth animation
804
+ >>> await scroll_to_async(browser, button.id)
805
+ >>> # Scroll instantly to top of viewport
806
+ >>> await scroll_to_async(browser, button.id, behavior='instant', block='start')
807
+ """
808
+ if not browser.page:
809
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
810
+
811
+ start_time = time.time()
812
+ url_before = browser.page.url
813
+
814
+ # Scroll element into view using the element registry
815
+ scrolled = await browser.page.evaluate(
816
+ """
817
+ (args) => {
818
+ const el = window.sentience_registry[args.id];
819
+ if (el && el.scrollIntoView) {
820
+ el.scrollIntoView({
821
+ behavior: args.behavior,
822
+ block: args.block,
823
+ inline: 'nearest'
824
+ });
825
+ return true;
826
+ }
827
+ return false;
828
+ }
829
+ """,
830
+ {"id": element_id, "behavior": behavior, "block": block},
831
+ )
832
+
833
+ if not scrolled:
834
+ return ActionResult(
835
+ success=False,
836
+ duration_ms=int((time.time() - start_time) * 1000),
837
+ outcome="error",
838
+ error={"code": "scroll_failed", "reason": "Element not found or not scrollable"},
839
+ )
840
+
841
+ # Wait a bit for scroll to complete (especially for smooth scrolling)
842
+ wait_time = 500 if behavior == "smooth" else 100
843
+ await browser.page.wait_for_timeout(wait_time)
844
+
845
+ duration_ms = int((time.time() - start_time) * 1000)
846
+ url_after = browser.page.url
847
+ url_changed = url_before != url_after
848
+
849
+ outcome = "navigated" if url_changed else "dom_updated"
850
+
851
+ snapshot_after: Snapshot | None = None
852
+ if take_snapshot:
853
+ snapshot_after = await snapshot_async(browser)
854
+
855
+ return ActionResult(
856
+ success=True,
857
+ duration_ms=duration_ms,
858
+ outcome=outcome,
859
+ url_changed=url_changed,
860
+ snapshot_after=snapshot_after,
861
+ )
862
+
863
+
864
+ async def _highlight_rect_async(
865
+ browser: AsyncSentienceBrowser, rect: dict[str, float], duration_sec: float = 2.0
866
+ ) -> None:
867
+ """Highlight a rectangle with a red border overlay (async)"""
868
+ if not browser.page:
869
+ return
870
+
871
+ highlight_id = f"sentience_highlight_{int(time.time() * 1000)}"
872
+
873
+ args = {
874
+ "rect": {
875
+ "x": rect["x"],
876
+ "y": rect["y"],
877
+ "w": rect["w"],
878
+ "h": rect["h"],
879
+ },
880
+ "highlightId": highlight_id,
881
+ "durationSec": duration_sec,
882
+ }
883
+
884
+ await browser.page.evaluate(
885
+ """
886
+ (args) => {
887
+ const { rect, highlightId, durationSec } = args;
888
+ const overlay = document.createElement('div');
889
+ overlay.id = highlightId;
890
+ overlay.style.position = 'fixed';
891
+ overlay.style.left = `${rect.x}px`;
892
+ overlay.style.top = `${rect.y}px`;
893
+ overlay.style.width = `${rect.w}px`;
894
+ overlay.style.height = `${rect.h}px`;
895
+ overlay.style.border = '3px solid red';
896
+ overlay.style.borderRadius = '2px';
897
+ overlay.style.boxSizing = 'border-box';
898
+ overlay.style.pointerEvents = 'none';
899
+ overlay.style.zIndex = '999999';
900
+ overlay.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
901
+ overlay.style.transition = 'opacity 0.3s ease-out';
902
+
903
+ document.body.appendChild(overlay);
904
+
905
+ setTimeout(() => {
906
+ overlay.style.opacity = '0';
907
+ setTimeout(() => {
908
+ if (overlay.parentNode) {
909
+ overlay.parentNode.removeChild(overlay);
910
+ }
911
+ }, 300);
912
+ }, durationSec * 1000);
913
+ }
914
+ """,
915
+ args,
916
+ )
917
+
918
+
919
+ async def click_rect_async(
920
+ browser: AsyncSentienceBrowser,
921
+ rect: dict[str, float] | BBox,
922
+ highlight: bool = True,
923
+ highlight_duration: float = 2.0,
924
+ take_snapshot: bool = False,
925
+ ) -> ActionResult:
926
+ """
927
+ Click at the center of a rectangle (async)
928
+
929
+ Args:
930
+ browser: AsyncSentienceBrowser instance
931
+ rect: Dictionary with x, y, width (w), height (h) keys, or BBox object
932
+ highlight: Whether to show a red border highlight when clicking
933
+ highlight_duration: How long to show the highlight in seconds
934
+ take_snapshot: Whether to take snapshot after action
935
+
936
+ Returns:
937
+ ActionResult
938
+ """
939
+ if not browser.page:
940
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
941
+
942
+ # Handle BBox object or dict
943
+ if isinstance(rect, BBox):
944
+ x = rect.x
945
+ y = rect.y
946
+ w = rect.width
947
+ h = rect.height
948
+ else:
949
+ x = rect.get("x", 0)
950
+ y = rect.get("y", 0)
951
+ w = rect.get("w") or rect.get("width", 0)
952
+ h = rect.get("h") or rect.get("height", 0)
953
+
954
+ if w <= 0 or h <= 0:
955
+ return ActionResult(
956
+ success=False,
957
+ duration_ms=0,
958
+ outcome="error",
959
+ error={
960
+ "code": "invalid_rect",
961
+ "reason": "Rectangle width and height must be positive",
962
+ },
963
+ )
964
+
965
+ start_time = time.time()
966
+ url_before = browser.page.url
967
+
968
+ # Calculate center of rectangle
969
+ center_x = x + w / 2
970
+ center_y = y + h / 2
971
+
972
+ # Show highlight before clicking
973
+ if highlight:
974
+ await _highlight_rect_async(browser, {"x": x, "y": y, "w": w, "h": h}, highlight_duration)
975
+ await browser.page.wait_for_timeout(50)
976
+
977
+ # Use Playwright's native mouse click
978
+ try:
979
+ await browser.page.mouse.click(center_x, center_y)
980
+ success = True
981
+ except Exception as e:
982
+ success = False
983
+ error_msg = str(e)
984
+
985
+ # Wait a bit for navigation/DOM updates
986
+ await browser.page.wait_for_timeout(500)
987
+
988
+ duration_ms = int((time.time() - start_time) * 1000)
989
+ url_after = browser.page.url
990
+ url_changed = url_before != url_after
991
+
992
+ # Determine outcome
993
+ outcome: str | None = None
994
+ if url_changed:
995
+ outcome = "navigated"
996
+ elif success:
997
+ outcome = "dom_updated"
998
+ else:
999
+ outcome = "error"
1000
+
1001
+ # Optional snapshot after
1002
+ snapshot_after: Snapshot | None = None
1003
+ if take_snapshot:
1004
+ snapshot_after = await snapshot_async(browser)
1005
+
1006
+ return ActionResult(
1007
+ success=success,
1008
+ duration_ms=duration_ms,
1009
+ outcome=outcome,
1010
+ url_changed=url_changed,
1011
+ snapshot_after=snapshot_after,
1012
+ error=(
1013
+ None
1014
+ if success
1015
+ else {
1016
+ "code": "click_failed",
1017
+ "reason": error_msg if not success else "Click failed",
1018
+ }
1019
+ ),
1020
+ )