Skip to content

Commit e0a8d45

Browse files
authored
Merge pull request #149 from SentienceAPI/humanize
humanized mouse click
2 parents 47c9d06 + fb57341 commit e0a8d45

File tree

14 files changed

+444
-55
lines changed

14 files changed

+444
-55
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
Human-like cursor movement demo (Python SDK).
3+
4+
This example shows how to opt into human-like mouse movement before clicking,
5+
and how to read the returned cursor metadata for tracing/debugging.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from sentience import CursorPolicy, SentienceBrowser, click, find, snapshot
11+
12+
13+
def main() -> None:
14+
# NOTE: This uses a real browser via Playwright.
15+
with SentienceBrowser() as browser:
16+
browser.page.goto("https://example.com")
17+
browser.page.wait_for_load_state("networkidle")
18+
19+
snap = snapshot(browser)
20+
link = find(snap, "role=link")
21+
if not link:
22+
raise RuntimeError("No link found on page")
23+
24+
policy = CursorPolicy(
25+
mode="human",
26+
steps=18, # more steps => smoother
27+
duration_ms=350,
28+
jitter_px=1.2,
29+
overshoot_px=6.0,
30+
pause_before_click_ms=30,
31+
seed=123, # optional: makes motion deterministic for demos/tests
32+
)
33+
34+
result = click(browser, link.id, use_mouse=True, cursor_policy=policy)
35+
print("clicked:", result.success, "outcome:", result.outcome)
36+
print("cursor meta:", result.cursor)
37+
38+
39+
if __name__ == "__main__":
40+
main()
41+

sentience/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
# Tracing (v0.12.0+)
4444
from .cloud_tracing import CloudTraceSink, SentienceLogger
4545
from .conversational_agent import ConversationalAgent
46+
from .cursor_policy import CursorPolicy
4647
from .expect import expect
4748
from .generator import ScriptGenerator, generate
4849
from .inspector import Inspector, inspect
@@ -172,6 +173,7 @@
172173
"press",
173174
"scroll_to",
174175
"click_rect",
176+
"CursorPolicy",
175177
"wait_for",
176178
"expect",
177179
"Inspector",

sentience/action_executor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def execute(self, action_str: str, snap: Snapshot) -> dict[str, Any]:
9696
"element_id": element_id,
9797
"outcome": result.outcome,
9898
"url_changed": result.url_changed,
99+
"cursor": getattr(result, "cursor", None),
99100
}
100101

101102
# Parse TYPE(42, "hello world")
@@ -170,6 +171,7 @@ async def execute_async(self, action_str: str, snap: Snapshot) -> dict[str, Any]
170171
"element_id": element_id,
171172
"outcome": result.outcome,
172173
"url_changed": result.url_changed,
174+
"cursor": getattr(result, "cursor", None),
173175
}
174176

175177
# Parse TYPE(42, "hello world")

sentience/actions.py

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
Actions v1 - click, type, press
55
"""
66

7+
import asyncio
78
import time
89

910
from .browser import AsyncSentienceBrowser, SentienceBrowser
1011
from .browser_evaluator import BrowserEvaluator
12+
from .cursor_policy import CursorPolicy, build_human_cursor_path
1113
from .models import ActionResult, BBox, Snapshot
1214
from .sentience_methods import SentienceMethod
1315
from .snapshot import snapshot, snapshot_async
@@ -18,6 +20,7 @@ def click( # noqa: C901
1820
element_id: int,
1921
use_mouse: bool = True,
2022
take_snapshot: bool = False,
23+
cursor_policy: CursorPolicy | None = None,
2124
) -> ActionResult:
2225
"""
2326
Click an element by ID using hybrid approach (mouse simulation by default)
@@ -37,6 +40,7 @@ def click( # noqa: C901
3740

3841
start_time = time.time()
3942
url_before = browser.page.url
43+
cursor_meta: dict | None = None
4044

4145
if use_mouse:
4246
# Hybrid approach: Get element bbox from snapshot, calculate center, use mouse.click()
@@ -52,9 +56,49 @@ def click( # noqa: C901
5256
# Calculate center of element bbox
5357
center_x = element.bbox.x + element.bbox.width / 2
5458
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)
5660
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+
)
58102
success = True
59103
except Exception:
60104
# If navigation happens, mouse.click might fail, but that's OK
@@ -122,6 +166,7 @@ def click( # noqa: C901
122166
outcome=outcome,
123167
url_changed=url_changed,
124168
snapshot_after=snapshot_after,
169+
cursor=cursor_meta,
125170
error=(
126171
None
127172
if success
@@ -414,6 +459,7 @@ def click_rect(
414459
highlight: bool = True,
415460
highlight_duration: float = 2.0,
416461
take_snapshot: bool = False,
462+
cursor_policy: CursorPolicy | None = None,
417463
) -> ActionResult:
418464
"""
419465
Click at the center of a rectangle using Playwright's native mouse simulation.
@@ -469,6 +515,7 @@ def click_rect(
469515
# Calculate center of rectangle
470516
center_x = x + w / 2
471517
center_y = y + h / 2
518+
cursor_meta: dict | None = None
472519

473520
# Show highlight before clicking (if enabled)
474521
if highlight:
@@ -479,7 +526,35 @@ def click_rect(
479526
# Use Playwright's native mouse click for realistic simulation
480527
# This triggers hover, focus, mousedown, mouseup sequences
481528
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+
482556
browser.page.mouse.click(center_x, center_y)
557+
setattr(browser, "_sentience_cursor_pos", (float(center_x), float(center_y)))
483558
success = True
484559
except Exception as e:
485560
success = False
@@ -512,6 +587,7 @@ def click_rect(
512587
outcome=outcome,
513588
url_changed=url_changed,
514589
snapshot_after=snapshot_after,
590+
cursor=cursor_meta,
515591
error=(
516592
None
517593
if success
@@ -531,6 +607,7 @@ async def click_async(
531607
element_id: int,
532608
use_mouse: bool = True,
533609
take_snapshot: bool = False,
610+
cursor_policy: CursorPolicy | None = None,
534611
) -> ActionResult:
535612
"""
536613
Click an element by ID using hybrid approach (async)
@@ -549,6 +626,7 @@ async def click_async(
549626

550627
start_time = time.time()
551628
url_before = browser.page.url
629+
cursor_meta: dict | None = None
552630

553631
if use_mouse:
554632
try:
@@ -563,7 +641,44 @@ async def click_async(
563641
center_x = element.bbox.x + element.bbox.width / 2
564642
center_y = element.bbox.y + element.bbox.height / 2
565643
try:
566-
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+
)
567682
success = True
568683
except Exception:
569684
success = True
@@ -640,6 +755,7 @@ async def click_async(
640755
outcome=outcome,
641756
url_changed=url_changed,
642757
snapshot_after=snapshot_after,
758+
cursor=cursor_meta,
643759
error=(
644760
None
645761
if success
@@ -922,6 +1038,7 @@ async def click_rect_async(
9221038
highlight: bool = True,
9231039
highlight_duration: float = 2.0,
9241040
take_snapshot: bool = False,
1041+
cursor_policy: CursorPolicy | None = None,
9251042
) -> ActionResult:
9261043
"""
9271044
Click at the center of a rectangle (async)
@@ -968,6 +1085,7 @@ async def click_rect_async(
9681085
# Calculate center of rectangle
9691086
center_x = x + w / 2
9701087
center_y = y + h / 2
1088+
cursor_meta: dict | None = None
9711089

9721090
# Show highlight before clicking
9731091
if highlight:
@@ -976,7 +1094,35 @@ async def click_rect_async(
9761094

9771095
# Use Playwright's native mouse click
9781096
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+
9791124
await browser.page.mouse.click(center_x, center_y)
1125+
setattr(browser, "_sentience_cursor_pos", (float(center_x), float(center_y)))
9801126
success = True
9811127
except Exception as e:
9821128
success = False
@@ -1009,6 +1155,7 @@ async def click_rect_async(
10091155
outcome=outcome,
10101156
url_changed=url_changed,
10111157
snapshot_after=snapshot_after,
1158+
cursor=cursor_meta,
10121159
error=(
10131160
None
10141161
if success

sentience/agent.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ def act( # noqa: C901
355355
url_changed=result_dict.get("url_changed"),
356356
error=result_dict.get("error"),
357357
message=result_dict.get("message"),
358+
cursor=result_dict.get("cursor"),
358359
)
359360

360361
# Emit action execution trace event if tracer is enabled
@@ -391,6 +392,7 @@ def act( # noqa: C901
391392
"post_url": post_url,
392393
"elements": elements_data, # Add element data for overlay
393394
"target_element_id": result.element_id, # Highlight target in red
395+
"cursor": result.cursor,
394396
},
395397
step_id=step_id,
396398
)
@@ -445,6 +447,8 @@ def act( # noqa: C901
445447
),
446448
"duration_ms": duration_ms,
447449
}
450+
if result.cursor is not None:
451+
exec_data["cursor"] = result.cursor
448452

449453
# Add optional exec fields
450454
if result.element_id is not None:

sentience/agent_runtime.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ def assert_done(
341341
True if task is complete (assertion passed), False otherwise
342342
"""
343343
# Convenience wrapper for assert_ with required=True
344-
ok = self.assert_(predicate, label=label, required=True)
344+
ok = self.assertTrue(predicate, label=label, required=True)
345345
if ok:
346346
self._task_done = True
347347
self._task_done_label = label

0 commit comments

Comments
 (0)