sentienceapi 0.92.2__py3-none-any.whl → 0.98.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sentienceapi might be problematic. Click here for more details.

Files changed (64) hide show
  1. sentience/__init__.py +107 -2
  2. sentience/_extension_loader.py +156 -1
  3. sentience/action_executor.py +2 -0
  4. sentience/actions.py +354 -9
  5. sentience/agent.py +4 -0
  6. sentience/agent_runtime.py +840 -0
  7. sentience/asserts/__init__.py +70 -0
  8. sentience/asserts/expect.py +621 -0
  9. sentience/asserts/query.py +383 -0
  10. sentience/async_api.py +8 -1
  11. sentience/backends/__init__.py +137 -0
  12. sentience/backends/actions.py +372 -0
  13. sentience/backends/browser_use_adapter.py +241 -0
  14. sentience/backends/cdp_backend.py +393 -0
  15. sentience/backends/exceptions.py +211 -0
  16. sentience/backends/playwright_backend.py +194 -0
  17. sentience/backends/protocol.py +216 -0
  18. sentience/backends/sentience_context.py +469 -0
  19. sentience/backends/snapshot.py +483 -0
  20. sentience/browser.py +230 -74
  21. sentience/canonicalization.py +207 -0
  22. sentience/cloud_tracing.py +65 -24
  23. sentience/constants.py +6 -0
  24. sentience/cursor_policy.py +142 -0
  25. sentience/extension/content.js +35 -0
  26. sentience/extension/injected_api.js +310 -15
  27. sentience/extension/manifest.json +1 -1
  28. sentience/extension/pkg/sentience_core.d.ts +22 -22
  29. sentience/extension/pkg/sentience_core.js +192 -144
  30. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  31. sentience/extension/release.json +29 -29
  32. sentience/failure_artifacts.py +241 -0
  33. sentience/integrations/__init__.py +6 -0
  34. sentience/integrations/langchain/__init__.py +12 -0
  35. sentience/integrations/langchain/context.py +18 -0
  36. sentience/integrations/langchain/core.py +326 -0
  37. sentience/integrations/langchain/tools.py +180 -0
  38. sentience/integrations/models.py +46 -0
  39. sentience/integrations/pydanticai/__init__.py +15 -0
  40. sentience/integrations/pydanticai/deps.py +20 -0
  41. sentience/integrations/pydanticai/toolset.py +468 -0
  42. sentience/llm_provider.py +695 -18
  43. sentience/models.py +536 -3
  44. sentience/ordinal.py +280 -0
  45. sentience/query.py +66 -4
  46. sentience/schemas/trace_v1.json +27 -1
  47. sentience/snapshot.py +384 -93
  48. sentience/snapshot_diff.py +39 -54
  49. sentience/text_search.py +1 -0
  50. sentience/trace_event_builder.py +20 -1
  51. sentience/trace_indexing/indexer.py +3 -49
  52. sentience/tracer_factory.py +1 -3
  53. sentience/verification.py +618 -0
  54. sentience/visual_agent.py +3 -1
  55. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +198 -40
  56. sentienceapi-0.98.0.dist-info/RECORD +92 -0
  57. sentience/utils.py +0 -296
  58. sentienceapi-0.92.2.dist-info/RECORD +0 -65
  59. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
  60. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
  61. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
  62. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
  63. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
  64. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
sentience/actions.py CHANGED
@@ -4,10 +4,12 @@ from typing import Optional
4
4
  Actions v1 - click, type, press
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import time
8
9
 
9
10
  from .browser import AsyncSentienceBrowser, SentienceBrowser
10
11
  from .browser_evaluator import BrowserEvaluator
12
+ from .cursor_policy import CursorPolicy, build_human_cursor_path
11
13
  from .models import ActionResult, BBox, Snapshot
12
14
  from .sentience_methods import SentienceMethod
13
15
  from .snapshot import snapshot, snapshot_async
@@ -18,6 +20,7 @@ def click( # noqa: C901
18
20
  element_id: int,
19
21
  use_mouse: bool = True,
20
22
  take_snapshot: bool = False,
23
+ cursor_policy: CursorPolicy | None = None,
21
24
  ) -> ActionResult:
22
25
  """
23
26
  Click an element by ID using hybrid approach (mouse simulation by default)
@@ -37,6 +40,7 @@ def click( # noqa: C901
37
40
 
38
41
  start_time = time.time()
39
42
  url_before = browser.page.url
43
+ cursor_meta: dict | None = None
40
44
 
41
45
  if use_mouse:
42
46
  # Hybrid approach: Get element bbox from snapshot, calculate center, use mouse.click()
@@ -52,9 +56,49 @@ def click( # noqa: C901
52
56
  # Calculate center of element bbox
53
57
  center_x = element.bbox.x + element.bbox.width / 2
54
58
  center_y = element.bbox.y + element.bbox.height / 2
55
- # Use Playwright's native mouse click for realistic simulation
59
+ # Optional: human-like cursor movement (opt-in)
56
60
  try:
57
- browser.page.mouse.click(center_x, center_y)
61
+ if cursor_policy is not None and cursor_policy.mode == "human":
62
+ # Best-effort cursor state on browser instance
63
+ pos = getattr(browser, "_sentience_cursor_pos", None)
64
+ if not isinstance(pos, tuple) or len(pos) != 2:
65
+ try:
66
+ vp = browser.page.viewport_size or {}
67
+ pos = (
68
+ float(vp.get("width", 0)) / 2.0,
69
+ float(vp.get("height", 0)) / 2.0,
70
+ )
71
+ except Exception:
72
+ pos = (0.0, 0.0)
73
+
74
+ cursor_meta = build_human_cursor_path(
75
+ start=(float(pos[0]), float(pos[1])),
76
+ target=(float(center_x), float(center_y)),
77
+ policy=cursor_policy,
78
+ )
79
+ pts = cursor_meta.get("path", [])
80
+ steps = int(cursor_meta.get("steps") or max(1, len(pts)))
81
+ duration_ms = int(cursor_meta.get("duration_ms") or 0)
82
+ per_step_s = (
83
+ (duration_ms / max(1, len(pts))) / 1000.0 if duration_ms > 0 else 0.0
84
+ )
85
+ for p in pts:
86
+ browser.page.mouse.move(float(p["x"]), float(p["y"]))
87
+ if per_step_s > 0:
88
+ time.sleep(per_step_s)
89
+ pause_ms = int(cursor_meta.get("pause_before_click_ms") or 0)
90
+ if pause_ms > 0:
91
+ time.sleep(pause_ms / 1000.0)
92
+ browser.page.mouse.click(center_x, center_y)
93
+ setattr(
94
+ browser, "_sentience_cursor_pos", (float(center_x), float(center_y))
95
+ )
96
+ else:
97
+ # Default behavior (no regression)
98
+ browser.page.mouse.click(center_x, center_y)
99
+ setattr(
100
+ browser, "_sentience_cursor_pos", (float(center_x), float(center_y))
101
+ )
58
102
  success = True
59
103
  except Exception:
60
104
  # If navigation happens, mouse.click might fail, but that's OK
@@ -122,6 +166,7 @@ def click( # noqa: C901
122
166
  outcome=outcome,
123
167
  url_changed=url_changed,
124
168
  snapshot_after=snapshot_after,
169
+ cursor=cursor_meta,
125
170
  error=(
126
171
  None
127
172
  if success
@@ -134,7 +179,11 @@ def click( # noqa: C901
134
179
 
135
180
 
136
181
  def type_text(
137
- browser: SentienceBrowser, element_id: int, text: str, take_snapshot: bool = False
182
+ browser: SentienceBrowser,
183
+ element_id: int,
184
+ text: str,
185
+ take_snapshot: bool = False,
186
+ delay_ms: float = 0,
138
187
  ) -> ActionResult:
139
188
  """
140
189
  Type text into an element (focus then input)
@@ -144,9 +193,16 @@ def type_text(
144
193
  element_id: Element ID from snapshot
145
194
  text: Text to type
146
195
  take_snapshot: Whether to take snapshot after action
196
+ delay_ms: Delay between keystrokes in milliseconds for human-like typing (default: 0)
147
197
 
148
198
  Returns:
149
199
  ActionResult
200
+
201
+ Example:
202
+ >>> # Type instantly (default behavior)
203
+ >>> type_text(browser, element_id, "Hello World")
204
+ >>> # Type with human-like delay (~10ms between keystrokes)
205
+ >>> type_text(browser, element_id, "Hello World", delay_ms=10)
150
206
  """
151
207
  if not browser.page:
152
208
  raise RuntimeError("Browser not started. Call browser.start() first.")
@@ -177,8 +233,8 @@ def type_text(
177
233
  error={"code": "focus_failed", "reason": "Element not found"},
178
234
  )
179
235
 
180
- # Type using Playwright keyboard
181
- browser.page.keyboard.type(text)
236
+ # Type using Playwright keyboard with optional delay between keystrokes
237
+ browser.page.keyboard.type(text, delay=delay_ms)
182
238
 
183
239
  duration_ms = int((time.time() - start_time) * 1000)
184
240
  url_after = browser.page.url
@@ -242,6 +298,94 @@ def press(browser: SentienceBrowser, key: str, take_snapshot: bool = False) -> A
242
298
  )
243
299
 
244
300
 
301
+ def scroll_to(
302
+ browser: SentienceBrowser,
303
+ element_id: int,
304
+ behavior: str = "smooth",
305
+ block: str = "center",
306
+ take_snapshot: bool = False,
307
+ ) -> ActionResult:
308
+ """
309
+ Scroll an element into view
310
+
311
+ Scrolls the page so that the specified element is visible in the viewport.
312
+ Uses the element registry to find the element and scrollIntoView() to scroll it.
313
+
314
+ Args:
315
+ browser: SentienceBrowser instance
316
+ element_id: Element ID from snapshot to scroll into view
317
+ behavior: Scroll behavior - 'smooth', 'instant', or 'auto' (default: 'smooth')
318
+ block: Vertical alignment - 'start', 'center', 'end', or 'nearest' (default: 'center')
319
+ take_snapshot: Whether to take snapshot after action
320
+
321
+ Returns:
322
+ ActionResult
323
+
324
+ Example:
325
+ >>> snap = snapshot(browser)
326
+ >>> button = find(snap, 'role=button[name="Submit"]')
327
+ >>> if button:
328
+ >>> # Scroll element into view with smooth animation
329
+ >>> scroll_to(browser, button.id)
330
+ >>> # Scroll instantly to top of viewport
331
+ >>> scroll_to(browser, button.id, behavior='instant', block='start')
332
+ """
333
+ if not browser.page:
334
+ raise RuntimeError("Browser not started. Call browser.start() first.")
335
+
336
+ start_time = time.time()
337
+ url_before = browser.page.url
338
+
339
+ # Scroll element into view using the element registry
340
+ scrolled = browser.page.evaluate(
341
+ """
342
+ (args) => {
343
+ const el = window.sentience_registry[args.id];
344
+ if (el && el.scrollIntoView) {
345
+ el.scrollIntoView({
346
+ behavior: args.behavior,
347
+ block: args.block,
348
+ inline: 'nearest'
349
+ });
350
+ return true;
351
+ }
352
+ return false;
353
+ }
354
+ """,
355
+ {"id": element_id, "behavior": behavior, "block": block},
356
+ )
357
+
358
+ if not scrolled:
359
+ return ActionResult(
360
+ success=False,
361
+ duration_ms=int((time.time() - start_time) * 1000),
362
+ outcome="error",
363
+ error={"code": "scroll_failed", "reason": "Element not found or not scrollable"},
364
+ )
365
+
366
+ # Wait a bit for scroll to complete (especially for smooth scrolling)
367
+ wait_time = 500 if behavior == "smooth" else 100
368
+ browser.page.wait_for_timeout(wait_time)
369
+
370
+ duration_ms = int((time.time() - start_time) * 1000)
371
+ url_after = browser.page.url
372
+ url_changed = url_before != url_after
373
+
374
+ outcome = "navigated" if url_changed else "dom_updated"
375
+
376
+ snapshot_after: Snapshot | None = None
377
+ if take_snapshot:
378
+ snapshot_after = snapshot(browser)
379
+
380
+ return ActionResult(
381
+ success=True,
382
+ duration_ms=duration_ms,
383
+ outcome=outcome,
384
+ url_changed=url_changed,
385
+ snapshot_after=snapshot_after,
386
+ )
387
+
388
+
245
389
  def _highlight_rect(
246
390
  browser: SentienceBrowser, rect: dict[str, float], duration_sec: float = 2.0
247
391
  ) -> None:
@@ -315,6 +459,7 @@ def click_rect(
315
459
  highlight: bool = True,
316
460
  highlight_duration: float = 2.0,
317
461
  take_snapshot: bool = False,
462
+ cursor_policy: CursorPolicy | None = None,
318
463
  ) -> ActionResult:
319
464
  """
320
465
  Click at the center of a rectangle using Playwright's native mouse simulation.
@@ -370,6 +515,7 @@ def click_rect(
370
515
  # Calculate center of rectangle
371
516
  center_x = x + w / 2
372
517
  center_y = y + h / 2
518
+ cursor_meta: dict | None = None
373
519
 
374
520
  # Show highlight before clicking (if enabled)
375
521
  if highlight:
@@ -380,7 +526,35 @@ def click_rect(
380
526
  # Use Playwright's native mouse click for realistic simulation
381
527
  # This triggers hover, focus, mousedown, mouseup sequences
382
528
  try:
529
+ if cursor_policy is not None and cursor_policy.mode == "human":
530
+ pos = getattr(browser, "_sentience_cursor_pos", None)
531
+ if not isinstance(pos, tuple) or len(pos) != 2:
532
+ try:
533
+ vp = browser.page.viewport_size or {}
534
+ pos = (float(vp.get("width", 0)) / 2.0, float(vp.get("height", 0)) / 2.0)
535
+ except Exception:
536
+ pos = (0.0, 0.0)
537
+
538
+ cursor_meta = build_human_cursor_path(
539
+ start=(float(pos[0]), float(pos[1])),
540
+ target=(float(center_x), float(center_y)),
541
+ policy=cursor_policy,
542
+ )
543
+ pts = cursor_meta.get("path", [])
544
+ duration_ms_move = int(cursor_meta.get("duration_ms") or 0)
545
+ per_step_s = (
546
+ (duration_ms_move / max(1, len(pts))) / 1000.0 if duration_ms_move > 0 else 0.0
547
+ )
548
+ for p in pts:
549
+ browser.page.mouse.move(float(p["x"]), float(p["y"]))
550
+ if per_step_s > 0:
551
+ time.sleep(per_step_s)
552
+ pause_ms = int(cursor_meta.get("pause_before_click_ms") or 0)
553
+ if pause_ms > 0:
554
+ time.sleep(pause_ms / 1000.0)
555
+
383
556
  browser.page.mouse.click(center_x, center_y)
557
+ setattr(browser, "_sentience_cursor_pos", (float(center_x), float(center_y)))
384
558
  success = True
385
559
  except Exception as e:
386
560
  success = False
@@ -413,6 +587,7 @@ def click_rect(
413
587
  outcome=outcome,
414
588
  url_changed=url_changed,
415
589
  snapshot_after=snapshot_after,
590
+ cursor=cursor_meta,
416
591
  error=(
417
592
  None
418
593
  if success
@@ -432,6 +607,7 @@ async def click_async(
432
607
  element_id: int,
433
608
  use_mouse: bool = True,
434
609
  take_snapshot: bool = False,
610
+ cursor_policy: CursorPolicy | None = None,
435
611
  ) -> ActionResult:
436
612
  """
437
613
  Click an element by ID using hybrid approach (async)
@@ -450,6 +626,7 @@ async def click_async(
450
626
 
451
627
  start_time = time.time()
452
628
  url_before = browser.page.url
629
+ cursor_meta: dict | None = None
453
630
 
454
631
  if use_mouse:
455
632
  try:
@@ -464,7 +641,44 @@ async def click_async(
464
641
  center_x = element.bbox.x + element.bbox.width / 2
465
642
  center_y = element.bbox.y + element.bbox.height / 2
466
643
  try:
467
- await browser.page.mouse.click(center_x, center_y)
644
+ if cursor_policy is not None and cursor_policy.mode == "human":
645
+ pos = getattr(browser, "_sentience_cursor_pos", None)
646
+ if not isinstance(pos, tuple) or len(pos) != 2:
647
+ try:
648
+ vp = browser.page.viewport_size or {}
649
+ pos = (
650
+ float(vp.get("width", 0)) / 2.0,
651
+ float(vp.get("height", 0)) / 2.0,
652
+ )
653
+ except Exception:
654
+ pos = (0.0, 0.0)
655
+
656
+ cursor_meta = build_human_cursor_path(
657
+ start=(float(pos[0]), float(pos[1])),
658
+ target=(float(center_x), float(center_y)),
659
+ policy=cursor_policy,
660
+ )
661
+ pts = cursor_meta.get("path", [])
662
+ duration_ms = int(cursor_meta.get("duration_ms") or 0)
663
+ per_step_s = (
664
+ (duration_ms / max(1, len(pts))) / 1000.0 if duration_ms > 0 else 0.0
665
+ )
666
+ for p in pts:
667
+ await browser.page.mouse.move(float(p["x"]), float(p["y"]))
668
+ if per_step_s > 0:
669
+ await asyncio.sleep(per_step_s)
670
+ pause_ms = int(cursor_meta.get("pause_before_click_ms") or 0)
671
+ if pause_ms > 0:
672
+ await asyncio.sleep(pause_ms / 1000.0)
673
+ await browser.page.mouse.click(center_x, center_y)
674
+ setattr(
675
+ browser, "_sentience_cursor_pos", (float(center_x), float(center_y))
676
+ )
677
+ else:
678
+ await browser.page.mouse.click(center_x, center_y)
679
+ setattr(
680
+ browser, "_sentience_cursor_pos", (float(center_x), float(center_y))
681
+ )
468
682
  success = True
469
683
  except Exception:
470
684
  success = True
@@ -541,6 +755,7 @@ async def click_async(
541
755
  outcome=outcome,
542
756
  url_changed=url_changed,
543
757
  snapshot_after=snapshot_after,
758
+ cursor=cursor_meta,
544
759
  error=(
545
760
  None
546
761
  if success
@@ -553,7 +768,11 @@ async def click_async(
553
768
 
554
769
 
555
770
  async def type_text_async(
556
- browser: AsyncSentienceBrowser, element_id: int, text: str, take_snapshot: bool = False
771
+ browser: AsyncSentienceBrowser,
772
+ element_id: int,
773
+ text: str,
774
+ take_snapshot: bool = False,
775
+ delay_ms: float = 0,
557
776
  ) -> ActionResult:
558
777
  """
559
778
  Type text into an element (async)
@@ -563,9 +782,16 @@ async def type_text_async(
563
782
  element_id: Element ID from snapshot
564
783
  text: Text to type
565
784
  take_snapshot: Whether to take snapshot after action
785
+ delay_ms: Delay between keystrokes in milliseconds for human-like typing (default: 0)
566
786
 
567
787
  Returns:
568
788
  ActionResult
789
+
790
+ Example:
791
+ >>> # Type instantly (default behavior)
792
+ >>> await type_text_async(browser, element_id, "Hello World")
793
+ >>> # Type with human-like delay (~10ms between keystrokes)
794
+ >>> await type_text_async(browser, element_id, "Hello World", delay_ms=10)
569
795
  """
570
796
  if not browser.page:
571
797
  raise RuntimeError("Browser not started. Call await browser.start() first.")
@@ -596,8 +822,8 @@ async def type_text_async(
596
822
  error={"code": "focus_failed", "reason": "Element not found"},
597
823
  )
598
824
 
599
- # Type using Playwright keyboard
600
- await browser.page.keyboard.type(text)
825
+ # Type using Playwright keyboard with optional delay between keystrokes
826
+ await browser.page.keyboard.type(text, delay=delay_ms)
601
827
 
602
828
  duration_ms = int((time.time() - start_time) * 1000)
603
829
  url_after = browser.page.url
@@ -663,6 +889,94 @@ async def press_async(
663
889
  )
664
890
 
665
891
 
892
+ async def scroll_to_async(
893
+ browser: AsyncSentienceBrowser,
894
+ element_id: int,
895
+ behavior: str = "smooth",
896
+ block: str = "center",
897
+ take_snapshot: bool = False,
898
+ ) -> ActionResult:
899
+ """
900
+ Scroll an element into view (async)
901
+
902
+ Scrolls the page so that the specified element is visible in the viewport.
903
+ Uses the element registry to find the element and scrollIntoView() to scroll it.
904
+
905
+ Args:
906
+ browser: AsyncSentienceBrowser instance
907
+ element_id: Element ID from snapshot to scroll into view
908
+ behavior: Scroll behavior - 'smooth', 'instant', or 'auto' (default: 'smooth')
909
+ block: Vertical alignment - 'start', 'center', 'end', or 'nearest' (default: 'center')
910
+ take_snapshot: Whether to take snapshot after action
911
+
912
+ Returns:
913
+ ActionResult
914
+
915
+ Example:
916
+ >>> snap = await snapshot_async(browser)
917
+ >>> button = find(snap, 'role=button[name="Submit"]')
918
+ >>> if button:
919
+ >>> # Scroll element into view with smooth animation
920
+ >>> await scroll_to_async(browser, button.id)
921
+ >>> # Scroll instantly to top of viewport
922
+ >>> await scroll_to_async(browser, button.id, behavior='instant', block='start')
923
+ """
924
+ if not browser.page:
925
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
926
+
927
+ start_time = time.time()
928
+ url_before = browser.page.url
929
+
930
+ # Scroll element into view using the element registry
931
+ scrolled = await browser.page.evaluate(
932
+ """
933
+ (args) => {
934
+ const el = window.sentience_registry[args.id];
935
+ if (el && el.scrollIntoView) {
936
+ el.scrollIntoView({
937
+ behavior: args.behavior,
938
+ block: args.block,
939
+ inline: 'nearest'
940
+ });
941
+ return true;
942
+ }
943
+ return false;
944
+ }
945
+ """,
946
+ {"id": element_id, "behavior": behavior, "block": block},
947
+ )
948
+
949
+ if not scrolled:
950
+ return ActionResult(
951
+ success=False,
952
+ duration_ms=int((time.time() - start_time) * 1000),
953
+ outcome="error",
954
+ error={"code": "scroll_failed", "reason": "Element not found or not scrollable"},
955
+ )
956
+
957
+ # Wait a bit for scroll to complete (especially for smooth scrolling)
958
+ wait_time = 500 if behavior == "smooth" else 100
959
+ await browser.page.wait_for_timeout(wait_time)
960
+
961
+ duration_ms = int((time.time() - start_time) * 1000)
962
+ url_after = browser.page.url
963
+ url_changed = url_before != url_after
964
+
965
+ outcome = "navigated" if url_changed else "dom_updated"
966
+
967
+ snapshot_after: Snapshot | None = None
968
+ if take_snapshot:
969
+ snapshot_after = await snapshot_async(browser)
970
+
971
+ return ActionResult(
972
+ success=True,
973
+ duration_ms=duration_ms,
974
+ outcome=outcome,
975
+ url_changed=url_changed,
976
+ snapshot_after=snapshot_after,
977
+ )
978
+
979
+
666
980
  async def _highlight_rect_async(
667
981
  browser: AsyncSentienceBrowser, rect: dict[str, float], duration_sec: float = 2.0
668
982
  ) -> None:
@@ -724,6 +1038,7 @@ async def click_rect_async(
724
1038
  highlight: bool = True,
725
1039
  highlight_duration: float = 2.0,
726
1040
  take_snapshot: bool = False,
1041
+ cursor_policy: CursorPolicy | None = None,
727
1042
  ) -> ActionResult:
728
1043
  """
729
1044
  Click at the center of a rectangle (async)
@@ -770,6 +1085,7 @@ async def click_rect_async(
770
1085
  # Calculate center of rectangle
771
1086
  center_x = x + w / 2
772
1087
  center_y = y + h / 2
1088
+ cursor_meta: dict | None = None
773
1089
 
774
1090
  # Show highlight before clicking
775
1091
  if highlight:
@@ -778,7 +1094,35 @@ async def click_rect_async(
778
1094
 
779
1095
  # Use Playwright's native mouse click
780
1096
  try:
1097
+ if cursor_policy is not None and cursor_policy.mode == "human":
1098
+ pos = getattr(browser, "_sentience_cursor_pos", None)
1099
+ if not isinstance(pos, tuple) or len(pos) != 2:
1100
+ try:
1101
+ vp = browser.page.viewport_size or {}
1102
+ pos = (float(vp.get("width", 0)) / 2.0, float(vp.get("height", 0)) / 2.0)
1103
+ except Exception:
1104
+ pos = (0.0, 0.0)
1105
+
1106
+ cursor_meta = build_human_cursor_path(
1107
+ start=(float(pos[0]), float(pos[1])),
1108
+ target=(float(center_x), float(center_y)),
1109
+ policy=cursor_policy,
1110
+ )
1111
+ pts = cursor_meta.get("path", [])
1112
+ duration_ms_move = int(cursor_meta.get("duration_ms") or 0)
1113
+ per_step_s = (
1114
+ (duration_ms_move / max(1, len(pts))) / 1000.0 if duration_ms_move > 0 else 0.0
1115
+ )
1116
+ for p in pts:
1117
+ await browser.page.mouse.move(float(p["x"]), float(p["y"]))
1118
+ if per_step_s > 0:
1119
+ await asyncio.sleep(per_step_s)
1120
+ pause_ms = int(cursor_meta.get("pause_before_click_ms") or 0)
1121
+ if pause_ms > 0:
1122
+ await asyncio.sleep(pause_ms / 1000.0)
1123
+
781
1124
  await browser.page.mouse.click(center_x, center_y)
1125
+ setattr(browser, "_sentience_cursor_pos", (float(center_x), float(center_y)))
782
1126
  success = True
783
1127
  except Exception as e:
784
1128
  success = False
@@ -811,6 +1155,7 @@ async def click_rect_async(
811
1155
  outcome=outcome,
812
1156
  url_changed=url_changed,
813
1157
  snapshot_after=snapshot_after,
1158
+ cursor=cursor_meta,
814
1159
  error=(
815
1160
  None
816
1161
  if success
sentience/agent.py CHANGED
@@ -355,6 +355,7 @@ class SentienceAgent(BaseAgent):
355
355
  url_changed=result_dict.get("url_changed"),
356
356
  error=result_dict.get("error"),
357
357
  message=result_dict.get("message"),
358
+ cursor=result_dict.get("cursor"),
358
359
  )
359
360
 
360
361
  # Emit action execution trace event if tracer is enabled
@@ -391,6 +392,7 @@ class SentienceAgent(BaseAgent):
391
392
  "post_url": post_url,
392
393
  "elements": elements_data, # Add element data for overlay
393
394
  "target_element_id": result.element_id, # Highlight target in red
395
+ "cursor": result.cursor,
394
396
  },
395
397
  step_id=step_id,
396
398
  )
@@ -445,6 +447,8 @@ class SentienceAgent(BaseAgent):
445
447
  ),
446
448
  "duration_ms": duration_ms,
447
449
  }
450
+ if result.cursor is not None:
451
+ exec_data["cursor"] = result.cursor
448
452
 
449
453
  # Add optional exec fields
450
454
  if result.element_id is not None: