settled-computer 0.1.0a1__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.
- settled_computer/__init__.py +39 -0
- settled_computer/engine.py +581 -0
- settled_computer/server.py +754 -0
- settled_computer-0.1.0a1.dist-info/METADATA +284 -0
- settled_computer-0.1.0a1.dist-info/RECORD +9 -0
- settled_computer-0.1.0a1.dist-info/WHEEL +5 -0
- settled_computer-0.1.0a1.dist-info/entry_points.txt +2 -0
- settled_computer-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- settled_computer-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""settled-computer: event-driven settling for computer-use agents.
|
|
2
|
+
|
|
3
|
+
Every action waits until the screen reacts and stops changing, then returns the
|
|
4
|
+
settled frame plus a verdict — no fixed sleeps, no second screenshot round trip.
|
|
5
|
+
"""
|
|
6
|
+
from .engine import (
|
|
7
|
+
BusyProbe,
|
|
8
|
+
Frame,
|
|
9
|
+
Grabber,
|
|
10
|
+
LatencyBook,
|
|
11
|
+
Region,
|
|
12
|
+
SettleConfig,
|
|
13
|
+
SettleResult,
|
|
14
|
+
act_and_settle,
|
|
15
|
+
browser_busy_probe,
|
|
16
|
+
install_browser_probe,
|
|
17
|
+
make_mss_grabber,
|
|
18
|
+
playwright_grabber,
|
|
19
|
+
wait_settled,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0a1"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BusyProbe",
|
|
26
|
+
"Frame",
|
|
27
|
+
"Grabber",
|
|
28
|
+
"LatencyBook",
|
|
29
|
+
"Region",
|
|
30
|
+
"SettleConfig",
|
|
31
|
+
"SettleResult",
|
|
32
|
+
"act_and_settle",
|
|
33
|
+
"browser_busy_probe",
|
|
34
|
+
"install_browser_probe",
|
|
35
|
+
"make_mss_grabber",
|
|
36
|
+
"playwright_grabber",
|
|
37
|
+
"wait_settled",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
settle.py - event-driven "wait until the UI stops changing" for computer-use agents.
|
|
4
|
+
|
|
5
|
+
Replaces the fixed `sleep(N)` most agent harnesses run after every click/keypress.
|
|
6
|
+
|
|
7
|
+
How it works
|
|
8
|
+
------------
|
|
9
|
+
1. Grab a baseline frame BEFORE performing the action (so a fast reaction is never missed).
|
|
10
|
+
2. Perform the action.
|
|
11
|
+
3. Poll frames (~20 Hz), diffing at full resolution in 8x8-pixel cells, and wait for:
|
|
12
|
+
phase 1 (react): the screen changes, or `react_deadline` passes -> "no_reaction"
|
|
13
|
+
phase 2 (settle): no significant change for `quiet_time` -> "settled"
|
|
14
|
+
with `max_wait` as a hard cap -> "timeout"
|
|
15
|
+
4. Return the last full-resolution frame, so it doubles as the screenshot for the model
|
|
16
|
+
(no second capture) plus a short text note describing what happened.
|
|
17
|
+
|
|
18
|
+
Optional extra signals:
|
|
19
|
+
- `busy_probe`: any async/sync callable returning True while the app is busy
|
|
20
|
+
(browser: in-flight fetch/XHR, DOM mutations, running animations; desktop: busy cursor,
|
|
21
|
+
process CPU, etc.). See `browser_busy_probe` below.
|
|
22
|
+
- `ignore_regions`: fractions of the screen to mask out (clock, ticker, video).
|
|
23
|
+
|
|
24
|
+
Usage
|
|
25
|
+
-----
|
|
26
|
+
grab = make_mss_grabber() # desktop
|
|
27
|
+
res = await act_and_settle(grab, lambda: pyautogui.click(x, y))
|
|
28
|
+
send_to_model(image=res.frame, text=res.note_for_model())
|
|
29
|
+
|
|
30
|
+
# browser (Playwright, async API)
|
|
31
|
+
await install_browser_probe(page)
|
|
32
|
+
cfg = SettleConfig(pixel_delta=12, busy_probe=browser_busy_probe(page))
|
|
33
|
+
res = await act_and_settle(playwright_grabber(page), lambda: page.mouse.click(x, y), cfg)
|
|
34
|
+
|
|
35
|
+
Run the self-test: python settle.py --selftest
|
|
36
|
+
Requires: numpy. Optional: mss (desktop capture), Pillow (browser screenshots).
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import asyncio
|
|
42
|
+
import inspect
|
|
43
|
+
import io
|
|
44
|
+
import sys
|
|
45
|
+
import time
|
|
46
|
+
from collections import defaultdict, deque
|
|
47
|
+
from dataclasses import dataclass, replace
|
|
48
|
+
from typing import Awaitable, Callable, Optional, Sequence, Union
|
|
49
|
+
|
|
50
|
+
import numpy as np
|
|
51
|
+
|
|
52
|
+
Frame = np.ndarray # full-resolution uint8 frame: H x W x 4 (BGRA, fastest), H x W x 3, or H x W
|
|
53
|
+
Grabber = Callable[[], Union[Frame, Awaitable[Frame]]]
|
|
54
|
+
BusyProbe = Callable[[], Union[bool, Awaitable[bool]]]
|
|
55
|
+
Region = tuple # (x0, y0, x1, y1) as fractions of the screen, 0..1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------- config / result
|
|
59
|
+
@dataclass
|
|
60
|
+
class SettleConfig:
|
|
61
|
+
poll_interval: float = 0.05 # seconds between frames (~20 Hz)
|
|
62
|
+
react_deadline: float = 0.30 # max wait for ANY change after the action
|
|
63
|
+
quiet_time: float = 0.20 # continuous quiet needed to call it settled
|
|
64
|
+
max_wait: float = 8.0 # hard cap
|
|
65
|
+
pixel_delta: int = 0 # 0 = exact compare (fast path, right for desktop capture);
|
|
66
|
+
# >0 (e.g. 12) tolerates noise, e.g. JPEG browser screenshots
|
|
67
|
+
block: int = 8 # frames are compared in block x block pixel cells
|
|
68
|
+
react_blocks: int = 2 # changed cells that count as "the UI reacted" (one typed char ~ 2-4)
|
|
69
|
+
activity_blocks: int = 8 # changed cells that keep resetting the quiet timer
|
|
70
|
+
# (below this, e.g. a blinking caret, counts as residual motion)
|
|
71
|
+
ignore_regions: Sequence[Region] = ()
|
|
72
|
+
busy_probe: Optional[BusyProbe] = None
|
|
73
|
+
residual_bail_after: float = 1.5 # a confined animation (video/spinner) bails after this long
|
|
74
|
+
confined_area: float = 0.35 # changed area (screen fraction) still counts as "confined"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class SettleResult:
|
|
79
|
+
reason: str # "settled" | "no_reaction" | "timeout" | "residual"
|
|
80
|
+
reacted: bool # did the screen (or busy probe) show any reaction
|
|
81
|
+
waited: float # seconds spent waiting (excludes the action itself)
|
|
82
|
+
residual_motion: float # 0..1: share of quiet polls with tiny changes (spinner/caret hint)
|
|
83
|
+
frame: Frame # last full-resolution frame, ready to send to the model
|
|
84
|
+
motion_box: Optional[tuple] = None # (x0, y0, x1, y1) fractions bounding the animating area
|
|
85
|
+
spread: bool = False # timeout only: motion covers more than `confined_area`
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def settled(self) -> bool:
|
|
89
|
+
return self.reason in ("settled", "no_reaction")
|
|
90
|
+
|
|
91
|
+
def note_for_model(self, kind: str = "action") -> str:
|
|
92
|
+
"""One-line verdict for the model. `kind` ("wait", "move"/"hover", "type", anything else)
|
|
93
|
+
tunes the no-reaction wording, because "missed its target" makes no sense for a wait."""
|
|
94
|
+
if self.reason == "settled":
|
|
95
|
+
msg = f"Screen settled {self.waited:.2f}s after the action."
|
|
96
|
+
elif self.reason == "no_reaction":
|
|
97
|
+
if kind == "wait":
|
|
98
|
+
msg = f"Nothing changed during {self.waited:.1f}s."
|
|
99
|
+
elif kind in ("move", "hover"):
|
|
100
|
+
msg = f"No visible change within {self.waited:.2f}s (no hover effect appeared)."
|
|
101
|
+
elif kind == "type":
|
|
102
|
+
msg = (f"No visible change within {self.waited:.2f}s of typing. The focus may be elsewhere, "
|
|
103
|
+
"or the field does not echo characters (e.g. a password box).")
|
|
104
|
+
else:
|
|
105
|
+
msg = (f"No visible change within {self.waited:.2f}s of the action. It may simply have had no "
|
|
106
|
+
"visible effect (the control was already focused or already in that state), missed its "
|
|
107
|
+
"target, or the app is slow to respond.")
|
|
108
|
+
elif self.reason == "residual":
|
|
109
|
+
box = self.motion_box
|
|
110
|
+
where = f" in region [{box[0]:.2f},{box[1]:.2f},{box[2]:.2f},{box[3]:.2f}]" if box else ""
|
|
111
|
+
msg = (f"Screen settled {self.waited:.2f}s after the action, except a small region{where} that "
|
|
112
|
+
"keeps animating (video, ticker, spinner or progress indicator?). Everything outside it is "
|
|
113
|
+
"stable. If that region is the loading indicator for what you are waiting on, call wait() "
|
|
114
|
+
"instead of acting.")
|
|
115
|
+
else:
|
|
116
|
+
msg = (f"Screen was still changing after {self.waited:.1f}s (timeout). "
|
|
117
|
+
"Likely loading or animating; wait and re-check before acting.")
|
|
118
|
+
box = self.motion_box
|
|
119
|
+
if self.spread and box is not None:
|
|
120
|
+
msg += (f" Motion is spread across region [{box[0]:.2f},{box[1]:.2f},"
|
|
121
|
+
f"{box[2]:.2f},{box[3]:.2f}].")
|
|
122
|
+
if self.residual_motion > 0.3 and self.reason != "residual":
|
|
123
|
+
msg += " A small region is still animating (spinner, ticker or video?)."
|
|
124
|
+
return msg
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- core
|
|
128
|
+
async def _maybe_await(value):
|
|
129
|
+
return await value if inspect.isawaitable(value) else value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _mask(shape, regions: Sequence[Region]) -> np.ndarray:
|
|
133
|
+
"""Boolean grid (True = watched) at block resolution; `regions` are screen fractions to ignore."""
|
|
134
|
+
m = np.ones(shape, dtype=bool)
|
|
135
|
+
h, w = shape
|
|
136
|
+
for x0, y0, x1, y1 in regions:
|
|
137
|
+
m[int(y0 * h):int(np.ceil(y1 * h)), int(x0 * w):int(np.ceil(x1 * w))] = False
|
|
138
|
+
return m
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _as_u32(frame: Frame) -> np.ndarray:
|
|
142
|
+
"""One uint32 per pixel so equality checks touch 4x fewer elements (BGRA/RGBA frames are
|
|
143
|
+
viewed in place; other layouts are packed once)."""
|
|
144
|
+
if frame.ndim == 2:
|
|
145
|
+
return frame.astype(np.uint32)
|
|
146
|
+
if frame.shape[2] == 4 and frame.dtype == np.uint8 and frame.flags.c_contiguous:
|
|
147
|
+
return frame.view(np.uint32).reshape(frame.shape[:2])
|
|
148
|
+
packed = np.zeros(frame.shape[:2] + (4,), np.uint8)
|
|
149
|
+
packed[..., :3] = frame[..., :3]
|
|
150
|
+
return packed.view(np.uint32).reshape(frame.shape[:2])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _changed_blocks(prev: Frame, cur: Frame, delta: int, block: int, mask: np.ndarray) -> tuple[int, Optional[tuple]]:
|
|
154
|
+
"""Number of block x block cells containing a changed pixel, plus the fractional bounding box
|
|
155
|
+
of the change (None when nothing changed) for the confined-animation report.
|
|
156
|
+
Comparing at full resolution and then reducing with any() keeps thin text strokes visible,
|
|
157
|
+
which a strided downsample would skip. delta == 0 uses a fast exact compare (~1 ms per 1080p
|
|
158
|
+
frame when nothing changed); delta > 0 ignores per-channel differences up to `delta`."""
|
|
159
|
+
if prev.shape != cur.shape: # resolution changed mid-wait: treat everything as changed
|
|
160
|
+
return int(mask.size), (0.0, 0.0, 1.0, 1.0)
|
|
161
|
+
if delta <= 0:
|
|
162
|
+
changed = _as_u32(prev) != _as_u32(cur)
|
|
163
|
+
else:
|
|
164
|
+
d = np.maximum(prev, cur) - np.minimum(prev, cur) # uint8-safe |a - b|
|
|
165
|
+
if d.ndim == 3:
|
|
166
|
+
d = d[..., :3].max(axis=2)
|
|
167
|
+
changed = d > delta
|
|
168
|
+
if not changed.any():
|
|
169
|
+
return 0, None
|
|
170
|
+
hb, wb = changed.shape[0] // block, changed.shape[1] // block
|
|
171
|
+
grid = changed[:hb * block, :wb * block].reshape(hb, block, wb, block).any(axis=(1, 3)) & mask
|
|
172
|
+
n = int(grid.sum())
|
|
173
|
+
if n == 0:
|
|
174
|
+
return 0, None
|
|
175
|
+
rows = np.nonzero(grid.any(axis=1))[0]
|
|
176
|
+
cols = np.nonzero(grid.any(axis=0))[0]
|
|
177
|
+
h, w = grid.shape
|
|
178
|
+
box = (float(cols[0] / w), float(rows[0] / h),
|
|
179
|
+
float((cols[-1] + 1) / w), float((rows[-1] + 1) / h))
|
|
180
|
+
return n, box
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def wait_settled(grab: Grabber, cfg: Optional[SettleConfig] = None,
|
|
184
|
+
baseline: Optional[Frame] = None) -> SettleResult:
|
|
185
|
+
"""Wait until the screen reacts and then stops changing.
|
|
186
|
+
|
|
187
|
+
Pass `baseline` = a frame captured BEFORE the action. Without it, a reaction that
|
|
188
|
+
finishes before the first poll is invisible and gets misreported as "no_reaction".
|
|
189
|
+
"""
|
|
190
|
+
cfg = cfg or SettleConfig()
|
|
191
|
+
t0 = time.monotonic()
|
|
192
|
+
prev = baseline if baseline is not None else await _maybe_await(grab())
|
|
193
|
+
mask = _mask((prev.shape[0] // cfg.block, prev.shape[1] // cfg.block), cfg.ignore_regions)
|
|
194
|
+
|
|
195
|
+
frame = prev
|
|
196
|
+
reacted = False
|
|
197
|
+
last_activity = t0
|
|
198
|
+
last_busy = t0 # a busy app gets a short grace before "no_reaction", because
|
|
199
|
+
quiet_polls = tiny_polls = 0 # the busy->idle transition usually precedes the paint
|
|
200
|
+
poll = min(cfg.poll_interval, 0.015) # burst-then-relax: fast first polls catch fast UIs,
|
|
201
|
+
burst_until = t0 + 0.1 # then fall back to the configured cadence
|
|
202
|
+
motion: deque = deque() # (time, box) of recent changed polls, for the
|
|
203
|
+
waited_polls = 0 # confined-animation detector
|
|
204
|
+
|
|
205
|
+
while True:
|
|
206
|
+
await asyncio.sleep(0.0 if waited_polls == 0 else poll) # first poll is immediate: the
|
|
207
|
+
waited_polls += 1 # reaction is often already there
|
|
208
|
+
frame = await _maybe_await(grab())
|
|
209
|
+
now = time.monotonic()
|
|
210
|
+
|
|
211
|
+
n, box = _changed_blocks(prev, frame, cfg.pixel_delta, cfg.block, mask)
|
|
212
|
+
prev = frame
|
|
213
|
+
busy = bool(cfg.busy_probe and await _maybe_await(cfg.busy_probe()))
|
|
214
|
+
if busy:
|
|
215
|
+
last_busy = now
|
|
216
|
+
|
|
217
|
+
first_reaction = (not reacted) and n >= cfg.react_blocks
|
|
218
|
+
reacted = reacted or first_reaction
|
|
219
|
+
if first_reaction or n >= cfg.activity_blocks or busy:
|
|
220
|
+
last_activity = now
|
|
221
|
+
quiet_polls = tiny_polls = 0
|
|
222
|
+
else:
|
|
223
|
+
quiet_polls += 1
|
|
224
|
+
tiny_polls += 1 if n > 0 else 0
|
|
225
|
+
|
|
226
|
+
if n > 0 and box is not None:
|
|
227
|
+
motion.append((now, box))
|
|
228
|
+
while motion and motion[0][0] < now - cfg.residual_bail_after:
|
|
229
|
+
motion.popleft()
|
|
230
|
+
motion_box = None
|
|
231
|
+
if motion:
|
|
232
|
+
motion_box = (min(b[0] for _, b in motion), min(b[1] for _, b in motion),
|
|
233
|
+
max(b[2] for _, b in motion), max(b[3] for _, b in motion))
|
|
234
|
+
|
|
235
|
+
elapsed = now - t0
|
|
236
|
+
if now > burst_until:
|
|
237
|
+
poll = cfg.poll_interval
|
|
238
|
+
residual = tiny_polls / quiet_polls if quiet_polls else 0.0
|
|
239
|
+
|
|
240
|
+
if reacted and now - last_activity >= cfg.quiet_time:
|
|
241
|
+
return SettleResult("settled", True, elapsed, residual, frame)
|
|
242
|
+
if (reacted and motion_box is not None and elapsed >= cfg.residual_bail_after
|
|
243
|
+
and (motion_box[2] - motion_box[0]) * (motion_box[3] - motion_box[1]) <= cfg.confined_area):
|
|
244
|
+
# One confined region keeps animating (video, spinner, ticker): it will not settle,
|
|
245
|
+
# so return the current frame with its bounding box instead of burning max_wait.
|
|
246
|
+
return SettleResult("residual", True, elapsed, residual, frame, motion_box)
|
|
247
|
+
if not reacted and not busy and elapsed >= cfg.react_deadline \
|
|
248
|
+
and now - last_busy >= min(cfg.react_deadline, 0.1):
|
|
249
|
+
return SettleResult("no_reaction", False, elapsed, residual, frame)
|
|
250
|
+
if elapsed >= cfg.max_wait:
|
|
251
|
+
spread = (motion_box is not None
|
|
252
|
+
and (motion_box[2] - motion_box[0]) * (motion_box[3] - motion_box[1]) > cfg.confined_area)
|
|
253
|
+
return SettleResult("timeout", reacted, elapsed, residual, frame, motion_box, spread=spread)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def act_and_settle(grab: Grabber, action: Callable[[], object],
|
|
257
|
+
cfg: Optional[SettleConfig] = None) -> SettleResult:
|
|
258
|
+
"""Capture a baseline, run the action (sync or async), then wait for the UI to settle."""
|
|
259
|
+
baseline = await _maybe_await(grab())
|
|
260
|
+
await _maybe_await(action())
|
|
261
|
+
return await wait_settled(grab, cfg, baseline=baseline)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------- adaptive timeouts
|
|
265
|
+
class LatencyBook:
|
|
266
|
+
"""Learns how long settling takes per key (e.g. "click") so the `max_wait` cap tracks reality.
|
|
267
|
+
|
|
268
|
+
- Timeouts are recorded too (as the wait they hit). Learning from successes alone would let a
|
|
269
|
+
key that often times out learn a short cap from its few fast runs.
|
|
270
|
+
- Learning only ever shortens the cap below the configured value, and never touches fields the
|
|
271
|
+
user pinned through configure() (`pinned`).
|
|
272
|
+
- quiet_time is deliberately NOT learned: it protects against pauses between UI phases
|
|
273
|
+
(debounced search, dialog then network), which the time-to-last-motion cannot reveal."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, keep: int = 50):
|
|
276
|
+
self._samples = defaultdict(lambda: deque(maxlen=keep))
|
|
277
|
+
|
|
278
|
+
def record(self, key: str, result: SettleResult) -> None:
|
|
279
|
+
if result.reason in ("settled", "timeout"):
|
|
280
|
+
self._samples[key].append(result.waited)
|
|
281
|
+
|
|
282
|
+
def max_wait_for(self, key: str, default: float = 8.0, floor: float = 1.0,
|
|
283
|
+
factor: float = 3.0) -> float:
|
|
284
|
+
xs = sorted(self._samples.get(key, ()))
|
|
285
|
+
if len(xs) < 5:
|
|
286
|
+
return default
|
|
287
|
+
p95 = xs[min(len(xs) - 1, int(len(xs) * 0.95))]
|
|
288
|
+
return max(floor, min(default, p95 * factor))
|
|
289
|
+
|
|
290
|
+
def config_for(self, key: str, base: Optional[SettleConfig] = None,
|
|
291
|
+
pinned: frozenset = frozenset()) -> SettleConfig:
|
|
292
|
+
base = base or SettleConfig()
|
|
293
|
+
if "max_wait" in pinned:
|
|
294
|
+
return base
|
|
295
|
+
return replace(base, max_wait=self.max_wait_for(key, base.max_wait))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --------------------------------------------------------------------------- frame sources
|
|
299
|
+
def make_mss_grabber(monitor: int = 1) -> Grabber:
|
|
300
|
+
"""Desktop capture via `pip install mss`. Returns contiguous BGRA frames, which enables the fast
|
|
301
|
+
exact-compare path; drop the alpha channel (frame[:, :, :3]) only when encoding for the model."""
|
|
302
|
+
import mss
|
|
303
|
+
|
|
304
|
+
sct = (getattr(mss, "MSS", None) or mss.mss)() # mss.mss is deprecated in newer releases
|
|
305
|
+
|
|
306
|
+
def grab() -> Frame:
|
|
307
|
+
return np.asarray(sct.grab(sct.monitors[monitor]))
|
|
308
|
+
|
|
309
|
+
return grab
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def playwright_grabber(page, quality: int = 40) -> Grabber:
|
|
313
|
+
"""Browser frames via Playwright (async API). Use SettleConfig(pixel_delta=12) with these frames
|
|
314
|
+
to absorb JPEG noise. Screenshots cost ~50-150 ms each, so for browsers lean mainly on
|
|
315
|
+
`browser_busy_probe`."""
|
|
316
|
+
from PIL import Image
|
|
317
|
+
|
|
318
|
+
async def grab() -> Frame:
|
|
319
|
+
data = await page.screenshot(type="jpeg", quality=quality)
|
|
320
|
+
return np.asarray(Image.open(io.BytesIO(data)).convert("RGB"))
|
|
321
|
+
|
|
322
|
+
return grab
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# --------------------------------------------------------------------------- browser busy signal
|
|
326
|
+
_BROWSER_PROBE_JS = """
|
|
327
|
+
(() => {
|
|
328
|
+
if (window.__settle) return;
|
|
329
|
+
const s = window.__settle = { inflight: 0, lastMutation: performance.now() };
|
|
330
|
+
const origFetch = window.fetch;
|
|
331
|
+
window.fetch = function (...args) {
|
|
332
|
+
s.inflight++;
|
|
333
|
+
return origFetch.apply(this, args).finally(() => { s.inflight--; });
|
|
334
|
+
};
|
|
335
|
+
const origSend = XMLHttpRequest.prototype.send;
|
|
336
|
+
XMLHttpRequest.prototype.send = function (...args) {
|
|
337
|
+
s.inflight++;
|
|
338
|
+
this.addEventListener('loadend', () => { s.inflight--; }, { once: true });
|
|
339
|
+
return origSend.apply(this, args);
|
|
340
|
+
};
|
|
341
|
+
new MutationObserver(() => { s.lastMutation = performance.now(); })
|
|
342
|
+
.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
343
|
+
})();
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
_BROWSER_BUSY_JS = """
|
|
347
|
+
(quietMs) => {
|
|
348
|
+
const s = window.__settle;
|
|
349
|
+
if (!s) return false;
|
|
350
|
+
if (document.readyState !== 'complete') return true;
|
|
351
|
+
if (s.inflight > 0) return true;
|
|
352
|
+
if (performance.now() - s.lastMutation < quietMs) return true;
|
|
353
|
+
// Finite animations/transitions still running. Infinite ones (CSS spinners) are ignored
|
|
354
|
+
// on purpose, otherwise pages with a permanent spinner would never settle.
|
|
355
|
+
return document.getAnimations().some(a => {
|
|
356
|
+
if (a.playState !== 'running') return false;
|
|
357
|
+
const t = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming() : null;
|
|
358
|
+
return !t || t.endTime !== Infinity;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def install_browser_probe(page) -> None:
|
|
365
|
+
"""Inject the in-page tracker now and on every future navigation (Playwright async API)."""
|
|
366
|
+
await page.add_init_script(_BROWSER_PROBE_JS)
|
|
367
|
+
try:
|
|
368
|
+
await page.evaluate(_BROWSER_PROBE_JS)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass # page may be mid-navigation; the init script covers the next load
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def browser_busy_probe(page, mutation_quiet_ms: int = 100) -> BusyProbe:
|
|
374
|
+
async def probe() -> bool:
|
|
375
|
+
try:
|
|
376
|
+
return bool(await page.evaluate(_BROWSER_BUSY_JS, mutation_quiet_ms))
|
|
377
|
+
except Exception:
|
|
378
|
+
return True # execution context destroyed => navigation in progress
|
|
379
|
+
|
|
380
|
+
return probe
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# --------------------------------------------------------------------------- self-test
|
|
384
|
+
class _FakeScreen:
|
|
385
|
+
"""1080p synthetic screen driven by wall-clock time, for testing without a display."""
|
|
386
|
+
H, W = 1080, 1920
|
|
387
|
+
|
|
388
|
+
def __init__(self, react_at=None, animate_until=None, forever=False, caret=False,
|
|
389
|
+
glyph_at=None, box=None, flash=False):
|
|
390
|
+
self.t0 = time.monotonic()
|
|
391
|
+
self.react_at = react_at
|
|
392
|
+
self.animate_until = animate_until
|
|
393
|
+
self.forever = forever
|
|
394
|
+
self.caret = caret
|
|
395
|
+
self.glyph_at = glyph_at
|
|
396
|
+
self.box = box # optional (x0, y0, x1, y1) pixel region the animation moves within
|
|
397
|
+
self.flash = flash # full-screen animation: the whole screen rewrites every grab
|
|
398
|
+
self.flash_phase = False
|
|
399
|
+
|
|
400
|
+
def now(self) -> float:
|
|
401
|
+
return time.monotonic() - self.t0
|
|
402
|
+
|
|
403
|
+
def grab(self) -> Frame:
|
|
404
|
+
t = self.now()
|
|
405
|
+
f = np.zeros((self.H, self.W, 4), np.uint8) # BGRA like mss
|
|
406
|
+
if self.flash and self.react_at is not None and t >= self.react_at:
|
|
407
|
+
end = float("inf") if self.forever else self.animate_until
|
|
408
|
+
if t < end:
|
|
409
|
+
self.flash_phase = not self.flash_phase # changes on EVERY grab: cannot alias
|
|
410
|
+
if self.flash_phase:
|
|
411
|
+
f[:] = 200 # whole-screen animation: nothing is ever "settled"
|
|
412
|
+
return f
|
|
413
|
+
if self.caret and int(t / 0.25) % 2 == 0:
|
|
414
|
+
f[100:120, 100:103] = 255 # tiny blinking caret (3 cells)
|
|
415
|
+
if self.react_at is not None and t >= self.react_at:
|
|
416
|
+
end = float("inf") if self.forever else self.animate_until
|
|
417
|
+
step = int(min(t, end) * 30) # moves until `end`, then freezes (or runs forever)
|
|
418
|
+
if self.box:
|
|
419
|
+
x0, y0, x1, y1 = self.box
|
|
420
|
+
x = x0 + (step * 37) % max(1, (x1 - x0) - 300)
|
|
421
|
+
f[y0:y1, x:x + 300] = 200 # animation confined to a region (video/spinner)
|
|
422
|
+
else:
|
|
423
|
+
x = (step * 37) % (self.W - 300)
|
|
424
|
+
f[400:700, x:x + 300] = 200 # moving block == animation
|
|
425
|
+
if self.glyph_at is not None and t >= self.glyph_at:
|
|
426
|
+
f[600:616, 300:310] = 255 # one typed character (~4 cells), appears once and stays
|
|
427
|
+
return f
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def _selftest() -> int:
|
|
431
|
+
failures = 0
|
|
432
|
+
|
|
433
|
+
def check(name: str, cond: bool, res: SettleResult) -> None:
|
|
434
|
+
nonlocal failures
|
|
435
|
+
print(f"[{'PASS' if cond else 'FAIL'}] {name}: reason={res.reason} "
|
|
436
|
+
f"waited={res.waited:.2f}s residual={res.residual_motion:.2f}")
|
|
437
|
+
failures += 0 if cond else 1
|
|
438
|
+
|
|
439
|
+
# 1. reaction, animation, then settle (~0.6s animation end + 0.2s quiet)
|
|
440
|
+
s = _FakeScreen(react_at=0.1, animate_until=0.6)
|
|
441
|
+
r = await wait_settled(s.grab, SettleConfig())
|
|
442
|
+
check("animation then settle", r.reason == "settled" and 0.65 <= r.waited <= 1.1, r)
|
|
443
|
+
|
|
444
|
+
# 2. nothing happens -> "no_reaction" after react_deadline, not the full timeout
|
|
445
|
+
s = _FakeScreen()
|
|
446
|
+
r = await wait_settled(s.grab, SettleConfig())
|
|
447
|
+
check("no reaction detected", r.reason == "no_reaction" and 0.3 <= r.waited <= 0.5, r)
|
|
448
|
+
|
|
449
|
+
# 3. never settles -> hard timeout
|
|
450
|
+
s = _FakeScreen(react_at=0.05, forever=True)
|
|
451
|
+
r = await wait_settled(s.grab, SettleConfig(max_wait=1.0))
|
|
452
|
+
check("endless animation hits timeout", r.reason == "timeout" and r.waited >= 1.0, r)
|
|
453
|
+
|
|
454
|
+
# 3b. regression: a single typed character (a few cells) must count as a reaction
|
|
455
|
+
s = _FakeScreen(glyph_at=0.1)
|
|
456
|
+
r = await wait_settled(s.grab, SettleConfig())
|
|
457
|
+
check("one typed character is detected", r.reason == "settled" and r.residual_motion == 0, r)
|
|
458
|
+
|
|
459
|
+
# 4. blinking caret is ignored but reported as residual motion
|
|
460
|
+
s = _FakeScreen(react_at=0.1, animate_until=0.3, caret=True)
|
|
461
|
+
r = await wait_settled(s.grab, SettleConfig(quiet_time=0.6))
|
|
462
|
+
check("caret ignored, residual reported", r.reason == "settled" and r.residual_motion > 0, r)
|
|
463
|
+
|
|
464
|
+
# 5. baseline matters: an instant one-frame change right after the action
|
|
465
|
+
def instant(screen: _FakeScreen):
|
|
466
|
+
def action():
|
|
467
|
+
t = screen.now()
|
|
468
|
+
screen.react_at = t
|
|
469
|
+
screen.animate_until = t # static block appears immediately
|
|
470
|
+
return action
|
|
471
|
+
|
|
472
|
+
s = _FakeScreen()
|
|
473
|
+
r = await act_and_settle(s.grab, instant(s), SettleConfig())
|
|
474
|
+
check("baseline catches instant change", r.reason == "settled" and r.reacted, r)
|
|
475
|
+
|
|
476
|
+
s = _FakeScreen()
|
|
477
|
+
instant(s)()
|
|
478
|
+
r = await wait_settled(s.grab, SettleConfig()) # no baseline -> the pitfall
|
|
479
|
+
check("no baseline misses it (expected pitfall)", r.reason == "no_reaction", r)
|
|
480
|
+
|
|
481
|
+
# 6. busy probe holds the wait while the app is "thinking" with an unchanged screen
|
|
482
|
+
s = _FakeScreen(react_at=0.5, animate_until=0.5)
|
|
483
|
+
cfg = SettleConfig(busy_probe=lambda: s.now() < 0.5)
|
|
484
|
+
r = await wait_settled(s.grab, cfg)
|
|
485
|
+
check("busy probe prevents early exit", r.reason == "settled" and r.waited >= 0.6, r)
|
|
486
|
+
|
|
487
|
+
# 7. adaptive timeouts
|
|
488
|
+
book = LatencyBook()
|
|
489
|
+
for w in (0.3, 0.4, 0.35, 0.5, 0.45, 0.4):
|
|
490
|
+
book.record("app:click", SettleResult("settled", True, w, 0.0, np.zeros((1, 1))))
|
|
491
|
+
mw = book.max_wait_for("app:click")
|
|
492
|
+
ok = 1.0 <= mw < 8.0
|
|
493
|
+
print(f"[{'PASS' if ok else 'FAIL'}] adaptive max_wait: {mw:.2f}s")
|
|
494
|
+
failures += 0 if ok else 1
|
|
495
|
+
|
|
496
|
+
# 8. tolerant path (pixel_delta > 0): +-5 noise is ignored, a real change is not
|
|
497
|
+
rng = np.random.default_rng(0)
|
|
498
|
+
flat = np.full((1080, 1920, 3), 100, np.uint8)
|
|
499
|
+
noisy = np.clip(flat.astype(np.int16) + rng.integers(-5, 6, flat.shape), 0, 255).astype(np.uint8)
|
|
500
|
+
moved = flat.copy()
|
|
501
|
+
moved[300:340, 300:400] = 200
|
|
502
|
+
grid = _mask((1080 // 8, 1920 // 8), [])
|
|
503
|
+
n_noise, box_noise = _changed_blocks(flat, noisy, 12, 8, grid)
|
|
504
|
+
n_real, box_real = _changed_blocks(flat, moved, 12, 8, grid)
|
|
505
|
+
ok = n_noise == 0 and box_noise is None and n_real >= 8 and box_real is not None
|
|
506
|
+
print(f"[{'PASS' if ok else 'FAIL'}] pixel_delta tolerance: noise cells={n_noise}, real-change cells={n_real}")
|
|
507
|
+
failures += 0 if ok else 1
|
|
508
|
+
|
|
509
|
+
# 9. confined animation (video) -> "residual" bail with a bounding box, well before max_wait
|
|
510
|
+
s = _FakeScreen(react_at=0.05, forever=True, box=(1100, 250, 1600, 650))
|
|
511
|
+
r = await wait_settled(s.grab, SettleConfig(max_wait=8.0, residual_bail_after=1.5))
|
|
512
|
+
ok = (r.reason == "residual" and 1.5 <= r.waited <= 3.0 and r.motion_box is not None
|
|
513
|
+
and r.motion_box[0] >= 0.5 and r.reacted)
|
|
514
|
+
print(f"[{'PASS' if ok else 'FAIL'}] confined animation bails early: reason={r.reason} "
|
|
515
|
+
f"waited={r.waited:.2f}s box={r.motion_box}")
|
|
516
|
+
failures += 0 if ok else 1
|
|
517
|
+
|
|
518
|
+
# 9b. a full-screen animation is NOT confined: still hits the timeout, no box shortcut
|
|
519
|
+
s = _FakeScreen(react_at=0.05, forever=True, flash=True)
|
|
520
|
+
r = await wait_settled(s.grab, SettleConfig(max_wait=1.5, residual_bail_after=0.5))
|
|
521
|
+
ok = r.reason == "timeout" and r.waited >= 1.5
|
|
522
|
+
print(f"[{'PASS' if ok else 'FAIL'}] full-screen animation still times out: reason={r.reason} "
|
|
523
|
+
f"waited={r.waited:.2f}s")
|
|
524
|
+
failures += 0 if ok else 1
|
|
525
|
+
|
|
526
|
+
# 10. learning must not override explicit configuration, and must not ignore timeouts
|
|
527
|
+
book = LatencyBook()
|
|
528
|
+
for _ in range(10):
|
|
529
|
+
book.record("click", SettleResult("settled", True, 0.23, 0.0, np.zeros((1, 1))))
|
|
530
|
+
user = SettleConfig(quiet_time=1.0, max_wait=30.0)
|
|
531
|
+
pinned_cfg = book.config_for("click", user, pinned=frozenset({"max_wait"}))
|
|
532
|
+
free_cfg = book.config_for("click", user)
|
|
533
|
+
ok = (pinned_cfg.max_wait == 30.0 and pinned_cfg.quiet_time == 1.0
|
|
534
|
+
and free_cfg.max_wait == 1.0 and free_cfg.quiet_time == 1.0)
|
|
535
|
+
print(f"[{'PASS' if ok else 'FAIL'}] configure() pins beat learning: pinned max_wait={pinned_cfg.max_wait} "
|
|
536
|
+
f"unpinned={free_cfg.max_wait} quiet_time stays {free_cfg.quiet_time}")
|
|
537
|
+
failures += 0 if ok else 1
|
|
538
|
+
|
|
539
|
+
book = LatencyBook()
|
|
540
|
+
for _ in range(8):
|
|
541
|
+
book.record("load", SettleResult("settled", True, 0.23, 0.0, np.zeros((1, 1))))
|
|
542
|
+
before = book.max_wait_for("load")
|
|
543
|
+
for _ in range(3):
|
|
544
|
+
book.record("load", SettleResult("timeout", True, 1.0, 0.0, np.zeros((1, 1))))
|
|
545
|
+
after = book.max_wait_for("load")
|
|
546
|
+
ok = before == 1.0 and after >= 2.9
|
|
547
|
+
print(f"[{'PASS' if ok else 'FAIL'}] timeouts raise the learned cap: {before:.1f}s -> {after:.1f}s")
|
|
548
|
+
failures += 0 if ok else 1
|
|
549
|
+
|
|
550
|
+
# 11. notes are worded per action kind, and timeouts report their spread
|
|
551
|
+
z = np.zeros((1, 1))
|
|
552
|
+
n_wait = SettleResult("no_reaction", False, 1.0, 0.0, z).note_for_model("wait")
|
|
553
|
+
n_click = SettleResult("no_reaction", False, 0.3, 0.0, z).note_for_model("click")
|
|
554
|
+
n_spread = SettleResult("timeout", True, 8.0, 0.0, z, (0.0, 0.0, 1.0, 1.0), spread=True).note_for_model()
|
|
555
|
+
ok = ("Nothing changed" in n_wait and "missed" not in n_wait and "missed its target" in n_click
|
|
556
|
+
and "spread across" in n_spread)
|
|
557
|
+
print(f"[{'PASS' if ok else 'FAIL'}] per-kind notes and timeout spread")
|
|
558
|
+
failures += 0 if ok else 1
|
|
559
|
+
|
|
560
|
+
# 12. a timeout reports where the motion is (this was silently dropped before)
|
|
561
|
+
s = _FakeScreen(react_at=0.05, forever=True, flash=True)
|
|
562
|
+
r = await wait_settled(s.grab, SettleConfig(max_wait=1.0))
|
|
563
|
+
ok = r.reason == "timeout" and r.motion_box is not None and r.spread
|
|
564
|
+
print(f"[{'PASS' if ok else 'FAIL'}] timeout carries motion box: box={r.motion_box} spread={r.spread}")
|
|
565
|
+
failures += 0 if ok else 1
|
|
566
|
+
|
|
567
|
+
print("ALL PASSED" if failures == 0 else f"{failures} FAILED")
|
|
568
|
+
return failures
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def main() -> None:
|
|
572
|
+
ap = argparse.ArgumentParser(description="Event-driven UI settle-wait for computer-use agents")
|
|
573
|
+
ap.add_argument("--selftest", action="store_true", help="run synthetic-screen tests")
|
|
574
|
+
args = ap.parse_args()
|
|
575
|
+
if args.selftest:
|
|
576
|
+
sys.exit(1 if asyncio.run(_selftest()) else 0)
|
|
577
|
+
ap.print_help()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
if __name__ == "__main__":
|
|
581
|
+
main()
|