Skip to content

Commit 99c8380

Browse files
committed
Add ac_wait_for_image and ac_wait_for_pixel
Polling wait helpers fill the gap left by ac_wait_for_window / ac_wait_for_text — useful for 'click then wait until the spinner disappears' style flows. Both tools accept a ToolCallContext, so they emit progress notifications and abort on notifications/cancelled. Timeout becomes a TimeoutError surfaced as a clean tool error to the model.
1 parent 1589329 commit 99c8380

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,38 @@ def screen_tools() -> List[MCPTool]:
143143
handler=h.get_pixel,
144144
annotations=READ_ONLY,
145145
),
146+
MCPTool(
147+
name="ac_wait_for_image",
148+
description=("Poll the screen until ``image_path`` appears, "
149+
"returning its centre [x, y]. Raises after "
150+
"``timeout`` seconds. Cancellable: clients can "
151+
"send notifications/cancelled to abort."),
152+
input_schema=schema({
153+
"image_path": {"type": "string"},
154+
"timeout": {"type": "number"},
155+
"poll": {"type": "number"},
156+
"detect_threshold": {"type": "number"},
157+
}, required=["image_path"]),
158+
handler=h.wait_for_image,
159+
annotations=READ_ONLY,
160+
),
161+
MCPTool(
162+
name="ac_wait_for_pixel",
163+
description=("Poll pixel (x, y) until it matches ``target_rgb`` "
164+
"within ``tolerance`` per channel. Returns the "
165+
"actual [r, g, b] reading on match."),
166+
input_schema=schema({
167+
"x": {"type": "integer"},
168+
"y": {"type": "integer"},
169+
"target_rgb": {"type": "array",
170+
"items": {"type": "integer"}},
171+
"tolerance": {"type": "integer"},
172+
"timeout": {"type": "number"},
173+
"poll": {"type": "number"},
174+
}, required=["x", "y", "target_rgb"]),
175+
handler=h.wait_for_pixel,
176+
annotations=READ_ONLY,
177+
),
146178
MCPTool(
147179
name="ac_diff_screenshots",
148180
description=("Compare two screenshots and return the bounding "

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,60 @@ def get_pixel(x: int, y: int) -> List[int]:
178178
return [int(component) for component in pixel]
179179

180180

181+
def wait_for_image(image_path: str, timeout: float = 10.0,
182+
poll: float = 0.5,
183+
detect_threshold: float = 1.0,
184+
ctx: Any = None) -> List[int]:
185+
"""Poll for ``image_path`` on screen; return its centre [x, y] or raise."""
186+
import time as _time
187+
from je_auto_control.utils.exception.exceptions import ImageNotFoundException
188+
from je_auto_control.wrapper.auto_control_image import locate_image_center as _loc
189+
poll_seconds = max(0.05, float(poll))
190+
deadline = _time.monotonic() + float(timeout)
191+
while _time.monotonic() < deadline:
192+
if ctx is not None:
193+
ctx.check_cancelled()
194+
ctx.progress(_time.monotonic() - (deadline - float(timeout)),
195+
total=float(timeout),
196+
message=f"waiting for {image_path}")
197+
try:
198+
cx, cy = _loc(image_path,
199+
detect_threshold=float(detect_threshold))
200+
return [int(cx), int(cy)]
201+
except ImageNotFoundException:
202+
_time.sleep(poll_seconds)
203+
raise TimeoutError(
204+
f"wait_for_image timed out after {timeout}s: {image_path!r}"
205+
)
206+
207+
208+
def wait_for_pixel(x: int, y: int, target_rgb: List[int],
209+
tolerance: int = 8, timeout: float = 10.0,
210+
poll: float = 0.25,
211+
ctx: Any = None) -> List[int]:
212+
"""Poll until pixel ``(x, y)`` matches ``target_rgb`` within ``tolerance``."""
213+
import time as _time
214+
from je_auto_control.wrapper.auto_control_screen import get_pixel as _pixel
215+
if len(target_rgb) < 3:
216+
raise ValueError("target_rgb must contain at least 3 channels")
217+
target = [int(c) for c in target_rgb[:3]]
218+
tol = max(0, int(tolerance))
219+
poll_seconds = max(0.05, float(poll))
220+
deadline = _time.monotonic() + float(timeout)
221+
while _time.monotonic() < deadline:
222+
if ctx is not None:
223+
ctx.check_cancelled()
224+
raw = _pixel(int(x), int(y))
225+
if raw is not None and len(raw) >= 3:
226+
channels = [int(raw[i]) for i in range(3)]
227+
if all(abs(channels[i] - target[i]) <= tol for i in range(3)):
228+
return channels
229+
_time.sleep(poll_seconds)
230+
raise TimeoutError(
231+
f"wait_for_pixel timed out after {timeout}s at ({x}, {y})"
232+
)
233+
234+
181235
def diff_screenshots(image_path_a: str,
182236
image_path_b: str,
183237
threshold: int = 16,

test/unit_test/headless/test_mcp_server.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,68 @@ def test_logging_set_level_rejects_unknown_name():
12391239
assert response["error"]["code"] == -32602
12401240

12411241

1242+
def test_wait_for_image_returns_center_when_template_found(monkeypatch):
1243+
import je_auto_control.utils.mcp_server.tools._handlers as handlers
1244+
import je_auto_control.wrapper.auto_control_image as image_module
1245+
monkeypatch.setattr(image_module, "locate_image_center",
1246+
lambda image_path, detect_threshold=1.0: (42, 84))
1247+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
1248+
coords = by_name["ac_wait_for_image"].invoke({
1249+
"image_path": "needle.png", "timeout": 1.0, "poll": 0.05,
1250+
})
1251+
assert coords == [42, 84]
1252+
1253+
1254+
def test_wait_for_image_times_out_when_template_missing(monkeypatch):
1255+
from je_auto_control.utils.exception.exceptions import (
1256+
ImageNotFoundException,
1257+
)
1258+
import je_auto_control.wrapper.auto_control_image as image_module
1259+
1260+
def always_miss(image_path, detect_threshold=1.0):
1261+
raise ImageNotFoundException("nope")
1262+
1263+
monkeypatch.setattr(image_module, "locate_image_center", always_miss)
1264+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
1265+
try:
1266+
by_name["ac_wait_for_image"].invoke({
1267+
"image_path": "missing.png",
1268+
"timeout": 0.2, "poll": 0.05,
1269+
})
1270+
except TimeoutError as error:
1271+
assert "timed out" in str(error)
1272+
else:
1273+
raise AssertionError("expected TimeoutError")
1274+
1275+
1276+
def test_wait_for_pixel_returns_when_match(monkeypatch):
1277+
import je_auto_control.wrapper.auto_control_screen as screen_module
1278+
monkeypatch.setattr(screen_module, "get_pixel",
1279+
lambda x, y, hwnd=None: (255, 0, 0))
1280+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
1281+
rgb = by_name["ac_wait_for_pixel"].invoke({
1282+
"x": 1, "y": 2, "target_rgb": [255, 0, 0],
1283+
"tolerance": 2, "timeout": 0.5, "poll": 0.05,
1284+
})
1285+
assert rgb == [255, 0, 0]
1286+
1287+
1288+
def test_wait_for_pixel_times_out_when_color_never_matches(monkeypatch):
1289+
import je_auto_control.wrapper.auto_control_screen as screen_module
1290+
monkeypatch.setattr(screen_module, "get_pixel",
1291+
lambda x, y, hwnd=None: (0, 0, 0))
1292+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
1293+
try:
1294+
by_name["ac_wait_for_pixel"].invoke({
1295+
"x": 1, "y": 2, "target_rgb": [255, 0, 0],
1296+
"tolerance": 2, "timeout": 0.2, "poll": 0.05,
1297+
})
1298+
except TimeoutError:
1299+
pass
1300+
else:
1301+
raise AssertionError("expected TimeoutError")
1302+
1303+
12421304
def test_default_registry_lists_core_automation_tools():
12431305
names = {tool.name for tool in build_default_tool_registry()}
12441306
expected = {

0 commit comments

Comments
 (0)