sentienceapi 0.90.17__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 (50) hide show
  1. sentience/__init__.py +153 -0
  2. sentience/_extension_loader.py +40 -0
  3. sentience/actions.py +837 -0
  4. sentience/agent.py +1246 -0
  5. sentience/agent_config.py +43 -0
  6. sentience/async_api.py +101 -0
  7. sentience/base_agent.py +194 -0
  8. sentience/browser.py +1037 -0
  9. sentience/cli.py +130 -0
  10. sentience/cloud_tracing.py +382 -0
  11. sentience/conversational_agent.py +509 -0
  12. sentience/expect.py +188 -0
  13. sentience/extension/background.js +233 -0
  14. sentience/extension/content.js +298 -0
  15. sentience/extension/injected_api.js +1473 -0
  16. sentience/extension/manifest.json +36 -0
  17. sentience/extension/pkg/sentience_core.d.ts +51 -0
  18. sentience/extension/pkg/sentience_core.js +529 -0
  19. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  20. sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
  21. sentience/extension/release.json +115 -0
  22. sentience/extension/test-content.js +4 -0
  23. sentience/formatting.py +59 -0
  24. sentience/generator.py +202 -0
  25. sentience/inspector.py +365 -0
  26. sentience/llm_provider.py +637 -0
  27. sentience/models.py +412 -0
  28. sentience/overlay.py +222 -0
  29. sentience/query.py +303 -0
  30. sentience/read.py +185 -0
  31. sentience/recorder.py +589 -0
  32. sentience/schemas/trace_v1.json +216 -0
  33. sentience/screenshot.py +100 -0
  34. sentience/snapshot.py +516 -0
  35. sentience/text_search.py +290 -0
  36. sentience/trace_indexing/__init__.py +27 -0
  37. sentience/trace_indexing/index_schema.py +111 -0
  38. sentience/trace_indexing/indexer.py +357 -0
  39. sentience/tracer_factory.py +211 -0
  40. sentience/tracing.py +285 -0
  41. sentience/utils.py +296 -0
  42. sentience/wait.py +137 -0
  43. sentienceapi-0.90.17.dist-info/METADATA +917 -0
  44. sentienceapi-0.90.17.dist-info/RECORD +50 -0
  45. sentienceapi-0.90.17.dist-info/WHEEL +5 -0
  46. sentienceapi-0.90.17.dist-info/entry_points.txt +2 -0
  47. sentienceapi-0.90.17.dist-info/licenses/LICENSE +24 -0
  48. sentienceapi-0.90.17.dist-info/licenses/LICENSE-APACHE +201 -0
  49. sentienceapi-0.90.17.dist-info/licenses/LICENSE-MIT +21 -0
  50. sentienceapi-0.90.17.dist-info/top_level.txt +1 -0
sentience/actions.py ADDED
@@ -0,0 +1,837 @@
1
+ """
2
+ Actions v1 - click, type, press
3
+ """
4
+
5
+ import time
6
+
7
+ from .browser import AsyncSentienceBrowser, SentienceBrowser
8
+ from .models import ActionResult, BBox, Snapshot
9
+ from .snapshot import snapshot, snapshot_async
10
+
11
+
12
+ def click( # noqa: C901
13
+ browser: SentienceBrowser,
14
+ element_id: int,
15
+ use_mouse: bool = True,
16
+ take_snapshot: bool = False,
17
+ ) -> ActionResult:
18
+ """
19
+ Click an element by ID using hybrid approach (mouse simulation by default)
20
+
21
+ Args:
22
+ browser: SentienceBrowser instance
23
+ element_id: Element ID from snapshot
24
+ use_mouse: If True, use Playwright's mouse.click() at element center (hybrid approach).
25
+ If False, use JS-based window.sentience.click() (legacy).
26
+ take_snapshot: Whether to take snapshot after action
27
+
28
+ Returns:
29
+ ActionResult
30
+ """
31
+ if not browser.page:
32
+ raise RuntimeError("Browser not started. Call browser.start() first.")
33
+
34
+ start_time = time.time()
35
+ url_before = browser.page.url
36
+
37
+ if use_mouse:
38
+ # Hybrid approach: Get element bbox from snapshot, calculate center, use mouse.click()
39
+ try:
40
+ snap = snapshot(browser)
41
+ element = None
42
+ for el in snap.elements:
43
+ if el.id == element_id:
44
+ element = el
45
+ break
46
+
47
+ if element:
48
+ # Calculate center of element bbox
49
+ center_x = element.bbox.x + element.bbox.width / 2
50
+ center_y = element.bbox.y + element.bbox.height / 2
51
+ # Use Playwright's native mouse click for realistic simulation
52
+ try:
53
+ browser.page.mouse.click(center_x, center_y)
54
+ success = True
55
+ except Exception:
56
+ # If navigation happens, mouse.click might fail, but that's OK
57
+ # The click still happened, just check URL change
58
+ success = True
59
+ else:
60
+ # Fallback to JS click if element not found in snapshot
61
+ try:
62
+ success = browser.page.evaluate(
63
+ """
64
+ (id) => {
65
+ return window.sentience.click(id);
66
+ }
67
+ """,
68
+ element_id,
69
+ )
70
+ except Exception:
71
+ # Navigation might have destroyed context, assume success if URL changed
72
+ success = True
73
+ except Exception:
74
+ # Fallback to JS click on error
75
+ try:
76
+ success = browser.page.evaluate(
77
+ """
78
+ (id) => {
79
+ return window.sentience.click(id);
80
+ }
81
+ """,
82
+ element_id,
83
+ )
84
+ except Exception:
85
+ # Navigation might have destroyed context, assume success if URL changed
86
+ success = True
87
+ else:
88
+ # Legacy JS-based click
89
+ success = browser.page.evaluate(
90
+ """
91
+ (id) => {
92
+ return window.sentience.click(id);
93
+ }
94
+ """,
95
+ element_id,
96
+ )
97
+
98
+ # Wait a bit for navigation/DOM updates
99
+ try:
100
+ browser.page.wait_for_timeout(500)
101
+ except Exception:
102
+ # Navigation might have happened, context destroyed
103
+ pass
104
+
105
+ duration_ms = int((time.time() - start_time) * 1000)
106
+
107
+ # Check if URL changed (handle navigation gracefully)
108
+ try:
109
+ url_after = browser.page.url
110
+ url_changed = url_before != url_after
111
+ except Exception:
112
+ # Context destroyed due to navigation - assume URL changed
113
+ url_after = url_before
114
+ url_changed = True
115
+
116
+ # Determine outcome
117
+ outcome: str | None = None
118
+ if url_changed:
119
+ outcome = "navigated"
120
+ elif success:
121
+ outcome = "dom_updated"
122
+ else:
123
+ outcome = "error"
124
+
125
+ # Optional snapshot after
126
+ snapshot_after: Snapshot | None = None
127
+ if take_snapshot:
128
+ try:
129
+ snapshot_after = snapshot(browser)
130
+ except Exception:
131
+ # Navigation might have destroyed context
132
+ pass
133
+
134
+ return ActionResult(
135
+ success=success,
136
+ duration_ms=duration_ms,
137
+ outcome=outcome,
138
+ url_changed=url_changed,
139
+ snapshot_after=snapshot_after,
140
+ error=(
141
+ None
142
+ if success
143
+ else {
144
+ "code": "click_failed",
145
+ "reason": "Element not found or not clickable",
146
+ }
147
+ ),
148
+ )
149
+
150
+
151
+ def type_text(
152
+ browser: SentienceBrowser, element_id: int, text: str, take_snapshot: bool = False
153
+ ) -> ActionResult:
154
+ """
155
+ Type text into an element (focus then input)
156
+
157
+ Args:
158
+ browser: SentienceBrowser instance
159
+ element_id: Element ID from snapshot
160
+ text: Text to type
161
+ take_snapshot: Whether to take snapshot after action
162
+
163
+ Returns:
164
+ ActionResult
165
+ """
166
+ if not browser.page:
167
+ raise RuntimeError("Browser not started. Call browser.start() first.")
168
+
169
+ start_time = time.time()
170
+ url_before = browser.page.url
171
+
172
+ # Focus element first using extension registry
173
+ focused = browser.page.evaluate(
174
+ """
175
+ (id) => {
176
+ const el = window.sentience_registry[id];
177
+ if (el) {
178
+ el.focus();
179
+ return true;
180
+ }
181
+ return false;
182
+ }
183
+ """,
184
+ element_id,
185
+ )
186
+
187
+ if not focused:
188
+ return ActionResult(
189
+ success=False,
190
+ duration_ms=int((time.time() - start_time) * 1000),
191
+ outcome="error",
192
+ error={"code": "focus_failed", "reason": "Element not found"},
193
+ )
194
+
195
+ # Type using Playwright keyboard
196
+ browser.page.keyboard.type(text)
197
+
198
+ duration_ms = int((time.time() - start_time) * 1000)
199
+ url_after = browser.page.url
200
+ url_changed = url_before != url_after
201
+
202
+ outcome = "navigated" if url_changed else "dom_updated"
203
+
204
+ snapshot_after: Snapshot | None = None
205
+ if take_snapshot:
206
+ snapshot_after = snapshot(browser)
207
+
208
+ return ActionResult(
209
+ success=True,
210
+ duration_ms=duration_ms,
211
+ outcome=outcome,
212
+ url_changed=url_changed,
213
+ snapshot_after=snapshot_after,
214
+ )
215
+
216
+
217
+ def press(browser: SentienceBrowser, key: str, take_snapshot: bool = False) -> ActionResult:
218
+ """
219
+ Press a keyboard key
220
+
221
+ Args:
222
+ browser: SentienceBrowser instance
223
+ key: Key to press (e.g., "Enter", "Escape", "Tab")
224
+ take_snapshot: Whether to take snapshot after action
225
+
226
+ Returns:
227
+ ActionResult
228
+ """
229
+ if not browser.page:
230
+ raise RuntimeError("Browser not started. Call browser.start() first.")
231
+
232
+ start_time = time.time()
233
+ url_before = browser.page.url
234
+
235
+ # Press key using Playwright
236
+ browser.page.keyboard.press(key)
237
+
238
+ # Wait a bit for navigation/DOM updates
239
+ browser.page.wait_for_timeout(500)
240
+
241
+ duration_ms = int((time.time() - start_time) * 1000)
242
+ url_after = browser.page.url
243
+ url_changed = url_before != url_after
244
+
245
+ outcome = "navigated" if url_changed else "dom_updated"
246
+
247
+ snapshot_after: Snapshot | None = None
248
+ if take_snapshot:
249
+ snapshot_after = snapshot(browser)
250
+
251
+ return ActionResult(
252
+ success=True,
253
+ duration_ms=duration_ms,
254
+ outcome=outcome,
255
+ url_changed=url_changed,
256
+ snapshot_after=snapshot_after,
257
+ )
258
+
259
+
260
+ def _highlight_rect(
261
+ browser: SentienceBrowser, rect: dict[str, float], duration_sec: float = 2.0
262
+ ) -> None:
263
+ """
264
+ Highlight a rectangle with a red border overlay
265
+
266
+ Args:
267
+ browser: SentienceBrowser instance
268
+ rect: Dictionary with x, y, width (w), height (h) keys
269
+ duration_sec: How long to show the highlight (default: 2 seconds)
270
+ """
271
+ if not browser.page:
272
+ return
273
+
274
+ # Create a unique ID for this highlight
275
+ highlight_id = f"sentience_highlight_{int(time.time() * 1000)}"
276
+
277
+ # Combine all arguments into a single object for Playwright
278
+ args = {
279
+ "rect": {
280
+ "x": rect["x"],
281
+ "y": rect["y"],
282
+ "w": rect["w"],
283
+ "h": rect["h"],
284
+ },
285
+ "highlightId": highlight_id,
286
+ "durationSec": duration_sec,
287
+ }
288
+
289
+ # Inject CSS and create overlay element
290
+ browser.page.evaluate(
291
+ """
292
+ (args) => {
293
+ const { rect, highlightId, durationSec } = args;
294
+ // Create overlay div
295
+ const overlay = document.createElement('div');
296
+ overlay.id = highlightId;
297
+ overlay.style.position = 'fixed';
298
+ overlay.style.left = `${rect.x}px`;
299
+ overlay.style.top = `${rect.y}px`;
300
+ overlay.style.width = `${rect.w}px`;
301
+ overlay.style.height = `${rect.h}px`;
302
+ overlay.style.border = '3px solid red';
303
+ overlay.style.borderRadius = '2px';
304
+ overlay.style.boxSizing = 'border-box';
305
+ overlay.style.pointerEvents = 'none';
306
+ overlay.style.zIndex = '999999';
307
+ overlay.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
308
+ overlay.style.transition = 'opacity 0.3s ease-out';
309
+
310
+ document.body.appendChild(overlay);
311
+
312
+ // Remove after duration
313
+ setTimeout(() => {
314
+ overlay.style.opacity = '0';
315
+ setTimeout(() => {
316
+ if (overlay.parentNode) {
317
+ overlay.parentNode.removeChild(overlay);
318
+ }
319
+ }, 300); // Wait for fade-out transition
320
+ }, durationSec * 1000);
321
+ }
322
+ """,
323
+ args,
324
+ )
325
+
326
+
327
+ def click_rect(
328
+ browser: SentienceBrowser,
329
+ rect: dict[str, float],
330
+ highlight: bool = True,
331
+ highlight_duration: float = 2.0,
332
+ take_snapshot: bool = False,
333
+ ) -> ActionResult:
334
+ """
335
+ Click at the center of a rectangle using Playwright's native mouse simulation.
336
+ This uses a hybrid approach: calculates center coordinates and uses mouse.click()
337
+ for realistic event simulation (triggers hover, focus, mousedown, mouseup).
338
+
339
+ Args:
340
+ browser: SentienceBrowser instance
341
+ rect: Dictionary with x, y, width (w), height (h) keys, or BBox object
342
+ highlight: Whether to show a red border highlight when clicking (default: True)
343
+ highlight_duration: How long to show the highlight in seconds (default: 2.0)
344
+ take_snapshot: Whether to take snapshot after action
345
+
346
+ Returns:
347
+ ActionResult
348
+
349
+ Example:
350
+ >>> click_rect(browser, {"x": 100, "y": 200, "w": 50, "h": 30})
351
+ >>> # Or using BBox object
352
+ >>> from sentience import BBox
353
+ >>> bbox = BBox(x=100, y=200, width=50, height=30)
354
+ >>> click_rect(browser, {"x": bbox.x, "y": bbox.y, "w": bbox.width, "h": bbox.height})
355
+ """
356
+ if not browser.page:
357
+ raise RuntimeError("Browser not started. Call browser.start() first.")
358
+
359
+ # Handle BBox object or dict
360
+ if isinstance(rect, BBox):
361
+ x = rect.x
362
+ y = rect.y
363
+ w = rect.width
364
+ h = rect.height
365
+ else:
366
+ x = rect.get("x", 0)
367
+ y = rect.get("y", 0)
368
+ w = rect.get("w") or rect.get("width", 0)
369
+ h = rect.get("h") or rect.get("height", 0)
370
+
371
+ if w <= 0 or h <= 0:
372
+ return ActionResult(
373
+ success=False,
374
+ duration_ms=0,
375
+ outcome="error",
376
+ error={
377
+ "code": "invalid_rect",
378
+ "reason": "Rectangle width and height must be positive",
379
+ },
380
+ )
381
+
382
+ start_time = time.time()
383
+ url_before = browser.page.url
384
+
385
+ # Calculate center of rectangle
386
+ center_x = x + w / 2
387
+ center_y = y + h / 2
388
+
389
+ # Show highlight before clicking (if enabled)
390
+ if highlight:
391
+ _highlight_rect(browser, {"x": x, "y": y, "w": w, "h": h}, highlight_duration)
392
+ # Small delay to ensure highlight is visible
393
+ browser.page.wait_for_timeout(50)
394
+
395
+ # Use Playwright's native mouse click for realistic simulation
396
+ # This triggers hover, focus, mousedown, mouseup sequences
397
+ try:
398
+ browser.page.mouse.click(center_x, center_y)
399
+ success = True
400
+ except Exception as e:
401
+ success = False
402
+ error_msg = str(e)
403
+
404
+ # Wait a bit for navigation/DOM updates
405
+ browser.page.wait_for_timeout(500)
406
+
407
+ duration_ms = int((time.time() - start_time) * 1000)
408
+ url_after = browser.page.url
409
+ url_changed = url_before != url_after
410
+
411
+ # Determine outcome
412
+ outcome: str | None = None
413
+ if url_changed:
414
+ outcome = "navigated"
415
+ elif success:
416
+ outcome = "dom_updated"
417
+ else:
418
+ outcome = "error"
419
+
420
+ # Optional snapshot after
421
+ snapshot_after: Snapshot | None = None
422
+ if take_snapshot:
423
+ snapshot_after = snapshot(browser)
424
+
425
+ return ActionResult(
426
+ success=success,
427
+ duration_ms=duration_ms,
428
+ outcome=outcome,
429
+ url_changed=url_changed,
430
+ snapshot_after=snapshot_after,
431
+ error=(
432
+ None
433
+ if success
434
+ else {
435
+ "code": "click_failed",
436
+ "reason": error_msg if not success else "Click failed",
437
+ }
438
+ ),
439
+ )
440
+
441
+
442
+ # ========== Async Action Functions ==========
443
+
444
+
445
+ async def click_async(
446
+ browser: AsyncSentienceBrowser,
447
+ element_id: int,
448
+ use_mouse: bool = True,
449
+ take_snapshot: bool = False,
450
+ ) -> ActionResult:
451
+ """
452
+ Click an element by ID using hybrid approach (async)
453
+
454
+ Args:
455
+ browser: AsyncSentienceBrowser instance
456
+ element_id: Element ID from snapshot
457
+ use_mouse: If True, use Playwright's mouse.click() at element center
458
+ take_snapshot: Whether to take snapshot after action
459
+
460
+ Returns:
461
+ ActionResult
462
+ """
463
+ if not browser.page:
464
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
465
+
466
+ start_time = time.time()
467
+ url_before = browser.page.url
468
+
469
+ if use_mouse:
470
+ try:
471
+ snap = await snapshot_async(browser)
472
+ element = None
473
+ for el in snap.elements:
474
+ if el.id == element_id:
475
+ element = el
476
+ break
477
+
478
+ if element:
479
+ center_x = element.bbox.x + element.bbox.width / 2
480
+ center_y = element.bbox.y + element.bbox.height / 2
481
+ try:
482
+ await browser.page.mouse.click(center_x, center_y)
483
+ success = True
484
+ except Exception:
485
+ success = True
486
+ else:
487
+ try:
488
+ success = await browser.page.evaluate(
489
+ """
490
+ (id) => {
491
+ return window.sentience.click(id);
492
+ }
493
+ """,
494
+ element_id,
495
+ )
496
+ except Exception:
497
+ success = True
498
+ except Exception:
499
+ try:
500
+ success = await browser.page.evaluate(
501
+ """
502
+ (id) => {
503
+ return window.sentience.click(id);
504
+ }
505
+ """,
506
+ element_id,
507
+ )
508
+ except Exception:
509
+ success = True
510
+ else:
511
+ success = await browser.page.evaluate(
512
+ """
513
+ (id) => {
514
+ return window.sentience.click(id);
515
+ }
516
+ """,
517
+ element_id,
518
+ )
519
+
520
+ # Wait a bit for navigation/DOM updates
521
+ try:
522
+ await browser.page.wait_for_timeout(500)
523
+ except Exception:
524
+ pass
525
+
526
+ duration_ms = int((time.time() - start_time) * 1000)
527
+
528
+ # Check if URL changed
529
+ try:
530
+ url_after = browser.page.url
531
+ url_changed = url_before != url_after
532
+ except Exception:
533
+ url_after = url_before
534
+ url_changed = True
535
+
536
+ # Determine outcome
537
+ outcome: str | None = None
538
+ if url_changed:
539
+ outcome = "navigated"
540
+ elif success:
541
+ outcome = "dom_updated"
542
+ else:
543
+ outcome = "error"
544
+
545
+ # Optional snapshot after
546
+ snapshot_after: Snapshot | None = None
547
+ if take_snapshot:
548
+ try:
549
+ snapshot_after = await snapshot_async(browser)
550
+ except Exception:
551
+ pass
552
+
553
+ return ActionResult(
554
+ success=success,
555
+ duration_ms=duration_ms,
556
+ outcome=outcome,
557
+ url_changed=url_changed,
558
+ snapshot_after=snapshot_after,
559
+ error=(
560
+ None
561
+ if success
562
+ else {
563
+ "code": "click_failed",
564
+ "reason": "Element not found or not clickable",
565
+ }
566
+ ),
567
+ )
568
+
569
+
570
+ async def type_text_async(
571
+ browser: AsyncSentienceBrowser, element_id: int, text: str, take_snapshot: bool = False
572
+ ) -> ActionResult:
573
+ """
574
+ Type text into an element (async)
575
+
576
+ Args:
577
+ browser: AsyncSentienceBrowser instance
578
+ element_id: Element ID from snapshot
579
+ text: Text to type
580
+ take_snapshot: Whether to take snapshot after action
581
+
582
+ Returns:
583
+ ActionResult
584
+ """
585
+ if not browser.page:
586
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
587
+
588
+ start_time = time.time()
589
+ url_before = browser.page.url
590
+
591
+ # Focus element first
592
+ focused = await browser.page.evaluate(
593
+ """
594
+ (id) => {
595
+ const el = window.sentience_registry[id];
596
+ if (el) {
597
+ el.focus();
598
+ return true;
599
+ }
600
+ return false;
601
+ }
602
+ """,
603
+ element_id,
604
+ )
605
+
606
+ if not focused:
607
+ return ActionResult(
608
+ success=False,
609
+ duration_ms=int((time.time() - start_time) * 1000),
610
+ outcome="error",
611
+ error={"code": "focus_failed", "reason": "Element not found"},
612
+ )
613
+
614
+ # Type using Playwright keyboard
615
+ await browser.page.keyboard.type(text)
616
+
617
+ duration_ms = int((time.time() - start_time) * 1000)
618
+ url_after = browser.page.url
619
+ url_changed = url_before != url_after
620
+
621
+ outcome = "navigated" if url_changed else "dom_updated"
622
+
623
+ snapshot_after: Snapshot | None = None
624
+ if take_snapshot:
625
+ snapshot_after = await snapshot_async(browser)
626
+
627
+ return ActionResult(
628
+ success=True,
629
+ duration_ms=duration_ms,
630
+ outcome=outcome,
631
+ url_changed=url_changed,
632
+ snapshot_after=snapshot_after,
633
+ )
634
+
635
+
636
+ async def press_async(
637
+ browser: AsyncSentienceBrowser, key: str, take_snapshot: bool = False
638
+ ) -> ActionResult:
639
+ """
640
+ Press a keyboard key (async)
641
+
642
+ Args:
643
+ browser: AsyncSentienceBrowser instance
644
+ key: Key to press (e.g., "Enter", "Escape", "Tab")
645
+ take_snapshot: Whether to take snapshot after action
646
+
647
+ Returns:
648
+ ActionResult
649
+ """
650
+ if not browser.page:
651
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
652
+
653
+ start_time = time.time()
654
+ url_before = browser.page.url
655
+
656
+ # Press key using Playwright
657
+ await browser.page.keyboard.press(key)
658
+
659
+ # Wait a bit for navigation/DOM updates
660
+ await browser.page.wait_for_timeout(500)
661
+
662
+ duration_ms = int((time.time() - start_time) * 1000)
663
+ url_after = browser.page.url
664
+ url_changed = url_before != url_after
665
+
666
+ outcome = "navigated" if url_changed else "dom_updated"
667
+
668
+ snapshot_after: Snapshot | None = None
669
+ if take_snapshot:
670
+ snapshot_after = await snapshot_async(browser)
671
+
672
+ return ActionResult(
673
+ success=True,
674
+ duration_ms=duration_ms,
675
+ outcome=outcome,
676
+ url_changed=url_changed,
677
+ snapshot_after=snapshot_after,
678
+ )
679
+
680
+
681
+ async def _highlight_rect_async(
682
+ browser: AsyncSentienceBrowser, rect: dict[str, float], duration_sec: float = 2.0
683
+ ) -> None:
684
+ """Highlight a rectangle with a red border overlay (async)"""
685
+ if not browser.page:
686
+ return
687
+
688
+ highlight_id = f"sentience_highlight_{int(time.time() * 1000)}"
689
+
690
+ args = {
691
+ "rect": {
692
+ "x": rect["x"],
693
+ "y": rect["y"],
694
+ "w": rect["w"],
695
+ "h": rect["h"],
696
+ },
697
+ "highlightId": highlight_id,
698
+ "durationSec": duration_sec,
699
+ }
700
+
701
+ await browser.page.evaluate(
702
+ """
703
+ (args) => {
704
+ const { rect, highlightId, durationSec } = args;
705
+ const overlay = document.createElement('div');
706
+ overlay.id = highlightId;
707
+ overlay.style.position = 'fixed';
708
+ overlay.style.left = `${rect.x}px`;
709
+ overlay.style.top = `${rect.y}px`;
710
+ overlay.style.width = `${rect.w}px`;
711
+ overlay.style.height = `${rect.h}px`;
712
+ overlay.style.border = '3px solid red';
713
+ overlay.style.borderRadius = '2px';
714
+ overlay.style.boxSizing = 'border-box';
715
+ overlay.style.pointerEvents = 'none';
716
+ overlay.style.zIndex = '999999';
717
+ overlay.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
718
+ overlay.style.transition = 'opacity 0.3s ease-out';
719
+
720
+ document.body.appendChild(overlay);
721
+
722
+ setTimeout(() => {
723
+ overlay.style.opacity = '0';
724
+ setTimeout(() => {
725
+ if (overlay.parentNode) {
726
+ overlay.parentNode.removeChild(overlay);
727
+ }
728
+ }, 300);
729
+ }, durationSec * 1000);
730
+ }
731
+ """,
732
+ args,
733
+ )
734
+
735
+
736
+ async def click_rect_async(
737
+ browser: AsyncSentienceBrowser,
738
+ rect: dict[str, float] | BBox,
739
+ highlight: bool = True,
740
+ highlight_duration: float = 2.0,
741
+ take_snapshot: bool = False,
742
+ ) -> ActionResult:
743
+ """
744
+ Click at the center of a rectangle (async)
745
+
746
+ Args:
747
+ browser: AsyncSentienceBrowser instance
748
+ rect: Dictionary with x, y, width (w), height (h) keys, or BBox object
749
+ highlight: Whether to show a red border highlight when clicking
750
+ highlight_duration: How long to show the highlight in seconds
751
+ take_snapshot: Whether to take snapshot after action
752
+
753
+ Returns:
754
+ ActionResult
755
+ """
756
+ if not browser.page:
757
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
758
+
759
+ # Handle BBox object or dict
760
+ if isinstance(rect, BBox):
761
+ x = rect.x
762
+ y = rect.y
763
+ w = rect.width
764
+ h = rect.height
765
+ else:
766
+ x = rect.get("x", 0)
767
+ y = rect.get("y", 0)
768
+ w = rect.get("w") or rect.get("width", 0)
769
+ h = rect.get("h") or rect.get("height", 0)
770
+
771
+ if w <= 0 or h <= 0:
772
+ return ActionResult(
773
+ success=False,
774
+ duration_ms=0,
775
+ outcome="error",
776
+ error={
777
+ "code": "invalid_rect",
778
+ "reason": "Rectangle width and height must be positive",
779
+ },
780
+ )
781
+
782
+ start_time = time.time()
783
+ url_before = browser.page.url
784
+
785
+ # Calculate center of rectangle
786
+ center_x = x + w / 2
787
+ center_y = y + h / 2
788
+
789
+ # Show highlight before clicking
790
+ if highlight:
791
+ await _highlight_rect_async(browser, {"x": x, "y": y, "w": w, "h": h}, highlight_duration)
792
+ await browser.page.wait_for_timeout(50)
793
+
794
+ # Use Playwright's native mouse click
795
+ try:
796
+ await browser.page.mouse.click(center_x, center_y)
797
+ success = True
798
+ except Exception as e:
799
+ success = False
800
+ error_msg = str(e)
801
+
802
+ # Wait a bit for navigation/DOM updates
803
+ await browser.page.wait_for_timeout(500)
804
+
805
+ duration_ms = int((time.time() - start_time) * 1000)
806
+ url_after = browser.page.url
807
+ url_changed = url_before != url_after
808
+
809
+ # Determine outcome
810
+ outcome: str | None = None
811
+ if url_changed:
812
+ outcome = "navigated"
813
+ elif success:
814
+ outcome = "dom_updated"
815
+ else:
816
+ outcome = "error"
817
+
818
+ # Optional snapshot after
819
+ snapshot_after: Snapshot | None = None
820
+ if take_snapshot:
821
+ snapshot_after = await snapshot_async(browser)
822
+
823
+ return ActionResult(
824
+ success=success,
825
+ duration_ms=duration_ms,
826
+ outcome=outcome,
827
+ url_changed=url_changed,
828
+ snapshot_after=snapshot_after,
829
+ error=(
830
+ None
831
+ if success
832
+ else {
833
+ "code": "click_failed",
834
+ "reason": error_msg if not success else "Click failed",
835
+ }
836
+ ),
837
+ )