fix: guard transient controls and verify timeline endpoints

This commit is contained in:
chenmingxuan committed 2026-11-02 13:54:50 +08:00
1 parent 479a185192
commit 00e62a663a
29 files changed
+1150 -90

No files matched your search

+23 -5
View File
@@ -190,7 +190,7 @@ class McpActionExecutor:
raw_name: str,
previous_screenshot: str | None,
previous_elements: list[dict[str, Any]] | None = None,
) -> tuple[ObserveResult, bool]:
) -> tuple[ObserveResult, bool | None]:
"""Prefer a settled image/tree pair after actions that can change screens.
The Android helper captures its bitmap and hierarchy concurrently. During a
@@ -198,6 +198,11 @@ class McpActionExecutor:
describes the new one. Bounded re-observation waits for adjacent stable
frames and rejects a new hierarchy paired with the previous page's bitmap.
"""
if raw_name == "action_sequence":
# Keep transient controls in the recorded post-image. Repeated settling
# captures can outlast the popup and replace useful evidence with a
# later closed state. This capture does not assert screen stability.
return await session.observe(settle_ms=0), None
first = await session.observe(settle_ms=400)
if not first.ok or raw_name not in _SCREEN_CHANGING_ACTIONS:
return first, first.ok
@@ -440,7 +445,7 @@ class McpActionExecutor:
@staticmethod
def _observe_despite_failure(raw_name: str, res: ActionResult) -> bool:
"""Capture the screen after launch failures and wait_for_text timeouts.
"""Capture the screen after runtime failures, including stale indexed taps.
Argument and package lookup errors do not require an observation.
"""
@@ -450,6 +455,8 @@ class McpActionExecutor:
return res.code is ActionCode.TIMEOUT
if raw_name in {"drag", "action_sequence"}:
return res.code not in {ActionCode.INVALID_ARGS, ActionCode.UNSUPPORTED}
if raw_name == "click":
return res.metadata.get("execution") in {"not_started", "unknown"}
return False
# --- Argument translation --------------------------------------------------------
@@ -474,18 +481,24 @@ class McpActionExecutor:
"""
if raw_name in {"drag", "action_sequence"}:
from artemis.config.agent import load_agent_config
from artemis.mcp.gestures import DragRequest, SequenceRequest, plan_drag, plan_sequence
from artemis.mcp.gestures import (
DragRequest,
plan_drag,
plan_sequence,
validate_sequence,
)
try:
if raw_name == "drag":
request = DragRequest.model_validate(args)
planned = plan_drag(request, *self._screen_size())
return "drag", request.model_dump(mode="json"), None, planned
sequence_request = SequenceRequest.model_validate(args)
limit = load_agent_config().pro.execution.max_burst_actions
sequence_request = validate_sequence(args, limit)
plan_sequence(
sequence_request,
*self._screen_size(),
limit=load_agent_config().pro.execution.max_burst_actions,
limit=limit,
)
return "action_sequence", sequence_request.model_dump(mode="json"), None, {}
except ValueError as exc:
@@ -505,6 +518,11 @@ class McpActionExecutor:
"target": target,
"times": args.get("times", 1),
"delay_ms": args.get("delay_ms", 100),
**(
{"expected_resource_id": recorded["target_resource_id"]}
if recorded.get("target_resource_id")
else {}
),
},
None,
recorded,
+8
View File
@@ -101,6 +101,14 @@ def to_canonical_call(
"target": [pair[0], pair[1]],
"times": action_item.get("times") or action_item.get("click_times") or 1,
"delay_ms": action_item.get("delay_ms") or action_item.get("delay") or 100,
**(
{
"expected_resource_id": action_item.get("resource_id")
or action_item.get("target_resource_id")
}
if action_item.get("resource_id") or action_item.get("target_resource_id")
else {}
),
}
if verb == "long_press_on":
+72 -16
View File
@@ -186,8 +186,11 @@ _TARGET_DESCRIPTION = (
async def _wire_click(actuator: Any, a: dict[str, Any]) -> ActionResult:
guard = (
{"expected_resource_id": a["expected_resource_id"]} if a.get("expected_resource_id") else {}
)
return await actuator.click(
int(a["target"][0]), int(a["target"][1]), times=a["times"], delay_ms=a["delay_ms"]
int(a["target"][0]), int(a["target"][1]), times=a["times"], delay_ms=a["delay_ms"], **guard
)
@@ -259,15 +262,30 @@ _SPECS: tuple[ActionSpec, ...] = (
operator=OperatorDialect(
description=(
"[ACTION] Execute a short sequence of click, wait and drag steps in ONE call, "
"in order, without model turns or screenshots between members. Use for "
"in order, without model turns between members; explicit visibility guards "
"may read the live UI locally. Use for "
"temporary overlays: open the control, wait briefly, then operate it before "
"it disappears. Use observed coordinates, or positions learned from an "
"earlier observation of the same restored layout; element indices are not "
"accepted. Each click/drag needs target_description. All members are "
"it disappears. For clicks prefer target=the exact observed resource_id string: "
"live bounds are resolved locally, refusing absent or ambiguous targets. "
"Otherwise use observed [x,y] in the declared coordinate_space; do not confuse "
"original screenshot pixels with 0..1000 normalized coordinates. Element indices "
"are not accepted. Each click/drag needs target_description. All members are "
"validated before any input. The configured fast-action limit applies. "
"The first failure stops the remainder; receipts identify completed and "
"unexecuted members. Never replay the whole sequence after a partial result. "
"Verify the final screen before claiming the intended result."
"Every wait counts as a member. "
"click.times (1..10) performs consecutive taps within ONE member, like the "
"ordinary click tool; prefer this over expanding repeated +/- taps into many "
"members/model turns. Each resource-targeted tap is revalidated locally; "
"missing targets stop remaining taps and receipts preserve completed_taps. "
"For maximum/minimum prefer observed native +/- controls, confirming that "
"further same-direction adjustment stops changing handle/ticks. An estimated "
"drag endpoint can be imprecise. For every auto-hiding adjustment, including "
"the reverse direction, keep opener/wait/operation in one call. "
"A prior screenshot does not prove the panel is still open. Use click.unless_visible "
"and drag.require_visible with an observed panel resource_id to avoid toggling an "
"open panel closed or dragging its underlying content. Never invent resource IDs. "
"Verify actual ticks/handle endpoints before claiming maximum or minimum."
),
params=(
ParamSpec("steps", list[GestureStep], "Ordered click, wait and drag members."),
@@ -286,8 +304,12 @@ _SPECS: tuple[ActionSpec, ...] = (
"[ACTION] Precisely drag a local slider or timeline. Use explicit start/end "
"or calibration from TWO observed numeric scale anchors. coordinate_space "
"is normalized (0..1000) or pixel (the current screenshot's actual pixels); "
"pixel avoids quantization on a zoomed scale. Default duration_ms=300: "
"slower is not always more precise and may trigger long-press recognition. "
"pixel avoids quantization on a zoomed scale. Default duration_ms=300. "
"If a panned timeline coasts past the release position, try a slower pan "
"(e.g. 1500ms), then wait for its ticks to stop before measuring the residual. "
"A transient release-time bubble can disagree with the final fixed-pointer "
"scale; do not treat it as final position proof. A permanently visible "
"timeline can be panned independently of its zoom popup. "
"Do not overshoot the endpoint automatically. For calibration, axis=x/y; "
"mode=thumb moves the handle to target_value, mode=pan shifts the scale "
"under indicator_position. Use one numeric unit, e.g. seconds since midnight, "
@@ -317,6 +339,12 @@ _SPECS: tuple[ActionSpec, ...] = (
"Observed numeric anchors that compute the endpoint. When supplied, end MUST be null; otherwise set calibration null.",
required=False,
),
ParamSpec(
"require_visible",
str | None,
"Previously observed panel resource_id; refuse the drag if it is no longer visible.",
required=False,
),
),
),
wire=WireDialect(
@@ -330,6 +358,7 @@ _SPECS: tuple[ActionSpec, ...] = (
),
ParamSpec("duration_ms", int, required=False, default=300),
ParamSpec("calibration", DragCalibration | None, required=False),
ParamSpec("require_visible", str | None, required=False),
),
bind=_wire_drag,
),
@@ -339,7 +368,10 @@ _SPECS: tuple[ActionSpec, ...] = (
operator=OperatorDialect(
description=(
"[ACTION] Click on the target location on the screen (supports element"
" index or absolute normalized coordinates)."
" index or absolute normalized coordinates). Indexed resource targets are checked "
"against live bounds before input; missing/moved targets refuse the tap. For "
"floating or auto-hiding panels, use a guarded action_sequence with opener and "
"target in the same call; an open panel in a screenshot may already have expired."
),
params=(
ParamSpec(
@@ -379,6 +411,12 @@ _SPECS: tuple[ActionSpec, ...] = (
ParamSpec("target", list[int]),
ParamSpec("times", int, required=False, default=1),
ParamSpec("delay_ms", int, required=False, default=100),
ParamSpec(
"expected_resource_id",
str | None,
"Observed index resource ID; refuse a stale tap outside its live bounds.",
required=False,
),
),
bind=_wire_click,
),
@@ -386,7 +424,8 @@ _SPECS: tuple[ActionSpec, ...] = (
"target: the agent dialect accepts an element index or a coordinate pair"
" (an index is resolved client-side against the indexed element list); the"
" wire takes coordinates only. target_description: recorded, never sent"
" to the wire; required only for coordinate targets."
" to the wire; required only for coordinate targets. Indexed resource identity is "
"sent as expected_resource_id for live bounds validation; coordinate-only calls omit it."
),
),
ActionSpec(
@@ -809,7 +848,7 @@ def exception_prefix(action: str) -> str:
@cache
def _operator_args_model(name: str):
def _operator_args_model(name: str, sequence_limit: int | None = None):
"""Builds (once) the pydantic argument model for an operator shell."""
dialect = ACTION_SPECS[name].operator
if dialect is None:
@@ -817,7 +856,12 @@ def _operator_args_model(name: str):
fields: dict[str, Any] = {}
for p in dialect.params:
if p.required:
fields[p.name] = (p.annotation, Field(description=p.description))
bounds = (
{"min_length": 1, "max_length": sequence_limit or 10}
if name == "action_sequence"
else {}
)
fields[p.name] = (p.annotation, Field(description=p.description, **bounds))
else:
fields[p.name] = (
p.annotation,
@@ -826,7 +870,7 @@ def _operator_args_model(name: str):
return create_model(name, **fields)
def operator_shell_tool(name: str) -> StructuredTool:
def operator_shell_tool(name: str, *, sequence_limit: int | None = None) -> StructuredTool:
"""Builds the Operator's declaration-only shell for one action.
The body is never executed by the Operator loop -- action calls are translated
@@ -838,8 +882,13 @@ def operator_shell_tool(name: str) -> StructuredTool:
raise ValueError(f"Action '{name}' has no operator dialect.")
return StructuredTool(
name=name,
description=dialect.description,
args_schema=_operator_args_model(name),
description=dialect.description
+ (
f" This task allows at most {sequence_limit} members per call, including waits."
if name == "action_sequence" and sequence_limit is not None
else ""
),
args_schema=_operator_args_model(name, sequence_limit),
func=lambda **kwargs: "Action Recorded",
)
@@ -900,7 +949,7 @@ def _projected_parameters(dialect: OperatorDialect, name: str) -> dict[str, Any]
@cache
def tool_declaration(name: str) -> ToolDeclaration:
def tool_declaration(name: str, *, sequence_limit: int | None = None) -> ToolDeclaration:
"""Builds the Flash ``ToolDeclaration`` for one action.
A shared action is the operator shell projected onto JSON (same description,
@@ -908,6 +957,13 @@ def tool_declaration(name: str) -> ToolDeclaration:
``DeclarationDialect``.
"""
spec = ACTION_SPECS[name]
if name == "action_sequence" and sequence_limit is not None:
shell = operator_shell_tool(name, sequence_limit=sequence_limit)
return ToolDeclaration(
name=name,
description=shell.description,
parameters=convert_to_openai_tool(shell)["function"]["parameters"],
)
if spec.declaration is not None:
return ToolDeclaration(
name=name,
+208 -12
View File
@@ -143,9 +143,26 @@ class AdbActuator:
return await self._dispatch_drag(planned)
async def _dispatch_drag(self, planned: dict) -> ActionResult:
error = await self.controller.swipe_coords(*planned["coordinates"], planned["duration_ms"])
metadata = dict(planned)
metadata.pop("action")
if resource_id := planned.get("require_visible"):
try:
guard = await self._visibility_guard(resource_id)
except (OSError, RuntimeError) as exc:
return ActionResult.failure(
"drag",
f"Visibility check failed; no input executed: {exc}",
metadata={**metadata, "execution": "not_started", "side_effect": "none"},
)
metadata["visibility_guard"] = guard
if not guard["visible"]:
return ActionResult.failure(
"drag",
"TARGET_NOT_FOUND: required panel control has disappeared; no drag executed. Re-establish the panel and operate it in one guarded action_sequence.",
code=ActionCode.TARGET_NOT_FOUND,
metadata={**metadata, "execution": "not_started", "side_effect": "none"},
)
error = await self.controller.swipe_coords(*planned["coordinates"], planned["duration_ms"])
if error:
return ActionResult.failure("drag", str(error), metadata=metadata)
return ActionResult.success(
@@ -156,20 +173,104 @@ class AdbActuator:
metadata=metadata,
)
async def _live_ui_elements(self) -> list[dict]:
elements = await self.controller.get_ui_elements()
if not isinstance(elements, list) or not elements:
raise RuntimeError("No live UI hierarchy available for the visibility guard.")
return elements
async def _visibility_guard(self, resource_id: str, elements: list[dict] | None = None) -> dict:
"""Read live UI locally; never treat failed/empty hierarchy as absence."""
if elements is None:
elements = await self._live_ui_elements()
visible = any(
(e.get("resource_id") or e.get("resource-id")) == resource_id
and e.get("visible-to-user", True) not in (False, "false")
for e in elements
if isinstance(e, dict)
)
return {"resource_id": resource_id, "visible": visible, "checked_at": time.time()}
def _live_target_coordinates(
self, resource_id: str, elements: list[dict], expected_point: tuple[int, int] | None = None
) -> list[int]:
from artemis.utils.visualization import parse_bounds
width, height = self._dims()
matches = []
for element in elements:
if (
element.get("resource_id") or element.get("resource-id")
) != resource_id or element.get("visible-to-user", True) in (False, "false"):
continue
raw_bounds = element.get("bounds")
bounds = parse_bounds(raw_bounds) if isinstance(raw_bounds, str) else raw_bounds
if not (
isinstance(bounds, (list, tuple))
and len(bounds) == 4
and all(isinstance(v, int) and not isinstance(v, bool) for v in bounds)
):
continue
if bounds:
left, top, right, bottom = bounds
left, top = max(0, left), max(0, top)
right, bottom = min(width, right), min(height, bottom)
if expected_point and not (
left <= expected_point[0] < right and top <= expected_point[1] < bottom
):
continue
if left < right and top < bottom:
matches.append([int((left + right) / 2), int((top + bottom) / 2)])
if len(matches) != 1:
raise LookupError(
f"TARGET_NOT_FOUND: {resource_id} has {len(matches)} visible matches; no further tap executed. Use a unique observed resource_id or re-observed coordinates."
)
return matches[0]
async def _tap_native_target(
self,
resource_id: str,
receipt: dict,
*,
times: int = 1,
delay_ms: int = 100,
elements: list[dict] | None = None,
expected_point: tuple[int, int] | None = None,
) -> str | None:
"""Revalidate every repeated native tap, retaining partial input receipts."""
receipt.update(completed_taps=0, execution="not_started")
for index in range(times):
if index:
await asyncio.sleep(max(0, delay_ms) / 1000)
elements = None
if elements is None:
elements = await self._live_ui_elements()
point = self._live_target_coordinates(resource_id, elements, expected_point)
coordinate_key = "pixel_coordinates" if expected_point is not None else "coordinates"
point = list(expected_point) if expected_point is not None else point
receipt[coordinate_key] = point
receipt["execution"] = "unknown"
result = await self.controller.tap_at(*point)
if error := getattr(result, "error", None):
return str(error)
receipt["completed_taps"] += 1
return None
async def action_sequence(self, steps: list[Any]) -> ActionResult:
"""Validate the entire sequence, then execute once with no intermediate observation."""
"""Execute once; optional visibility guards read live UI without model turns."""
import time
from artemis.config.agent import load_agent_config
from artemis.data_engine.trace import TraceSpan
from artemis.mcp.gestures import SequenceRequest, plan_sequence
from artemis.mcp.gestures import plan_sequence, validate_sequence
from artemis.runtime.task_control import TaskControl, has_pending_instruction
try:
limit = load_agent_config().pro.execution.max_burst_actions
planned = plan_sequence(
SequenceRequest.model_validate({"steps": steps}),
validate_sequence({"steps": steps}, limit),
*self._dims(),
limit=load_agent_config().pro.execution.max_burst_actions,
limit=limit,
)
except ValueError as exc:
return ActionResult.failure("action_sequence", str(exc), code=ActionCode.INVALID_ARGS)
@@ -178,6 +279,7 @@ class AdbActuator:
{**step, "index": i + 1, "execution": "not_started"} for i, step in enumerate(planned)
]
failed = None
failed_code = ActionCode.DEVICE_ERROR
for step, receipt in zip(planned, receipts, strict=True):
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl) and (
@@ -192,17 +294,65 @@ class AdbActuator:
span.payload = {"args": step}
receipt["started_ms"] = round((time.monotonic() - started) * 1000, 3)
receipt["execution"] = "unknown"
member_execution = "completed"
try:
if step["action"] == "click":
result = await self.controller.tap_at(*step["coordinates"])
error = getattr(result, "error", None)
guard = None
elements = None
error = None
if step.get("unless_visible") or step.get("target_resource_id"):
receipt["execution"] = "not_started"
elements = await self._live_ui_elements()
if resource_id := step.get("unless_visible"):
guard = await self._visibility_guard(resource_id, elements)
receipt["visibility_guard"] = guard
if guard and guard["visible"]:
member_execution = "skipped"
error = None
else:
if resource_id := step.get("target_resource_id"):
try:
error = await self._tap_native_target(
resource_id,
receipt,
times=step.get("times", 1),
delay_ms=step.get("delay_ms", 100),
elements=elements,
)
except LookupError as exc:
error = str(exc)
member_execution = receipt["execution"]
failed_code = ActionCode.TARGET_NOT_FOUND
else:
receipt["execution"] = "unknown"
repeats = (
{"times": step["times"], "delay_ms": step["delay_ms"]}
if "times" in step
else {}
)
result = await self.controller.tap_at(
*receipt["coordinates"], **repeats
)
error = getattr(result, "error", None)
elif step["action"] == "drag":
result = await self._dispatch_drag(step)
error = None if result.ok else result.message
if not result.ok:
member_execution = result.metadata.get("execution", "unknown")
failed_code = result.code
if guard_result := result.metadata.get("visibility_guard"):
receipt["visibility_guard"] = guard_result
else:
await asyncio.sleep(step["time_in_ms"] / 1000)
error = None
receipt.update(execution="unknown" if error else "completed", error=error)
receipt.update(
execution=(
member_execution
if member_execution in {"skipped", "not_started"} or not error
else "unknown"
),
error=error,
)
if error:
failed = str(error)
span.status = "failed"
@@ -211,7 +361,7 @@ class AdbActuator:
receipt.update(execution="unknown", error="Input cancelled; effect uncertain.")
raise
except (OSError, RuntimeError) as exc:
receipt.update(execution="unknown", error=str(exc))
receipt.update(error=str(exc))
failed = str(exc)
span.status = "failed"
span.error = failed
@@ -222,13 +372,17 @@ class AdbActuator:
if failed:
break
completed = sum(r["execution"] == "completed" for r in receipts)
attempted = any(r["action"] != "wait" and r["execution"] != "not_started" for r in receipts)
skipped = sum(r["execution"] == "skipped" for r in receipts)
attempted = any(
r["action"] != "wait" and r["execution"] in {"completed", "unknown"} for r in receipts
)
execution = "completed" if not failed else ("partial" if completed else "unknown")
if failed and not attempted:
execution = "not_started"
metadata = {
"sequence_results": receipts,
"completed_steps": completed,
"skipped_steps": skipped,
"execution": execution,
"side_effect": "possible" if attempted else "none",
}
@@ -237,11 +391,12 @@ class AdbActuator:
"action_sequence",
f"Sequence stopped after {completed}/{len(planned)} completed steps: {failed}"
" Remaining steps were not executed; do not replay completed inputs.",
code=failed_code,
metadata=metadata,
)
return ActionResult.success(
"action_sequence",
f"Executed {completed} gesture steps consecutively. Verify the final screen.",
f"Executed {completed} gesture steps consecutively; skipped {skipped} unnecessary opener clicks. Verify actual ticks/handle endpoints in the final screen.",
metadata=metadata,
)
@@ -270,8 +425,49 @@ class AdbActuator:
# --- Optional actions ------------------------------------------------------------
async def click(self, nx: int, ny: int, times: int = 1, delay_ms: int = 100) -> ActionResult:
async def click(
self,
nx: int,
ny: int,
times: int = 1,
delay_ms: int = 100,
*,
expected_resource_id: str | None = None,
) -> ActionResult:
x, y = self._to_px(nx, ny)
if expected_resource_id:
receipt: dict = {}
try:
error = await self._tap_native_target(
expected_resource_id,
receipt,
times=times,
delay_ms=delay_ms,
expected_point=(x, y),
)
except (LookupError, OSError, RuntimeError) as exc:
return ActionResult.failure(
"click",
f"Observed target could not be confirmed for the next tap; completed {receipt.get('completed_taps', 0)} taps: {exc}. Re-observe. For an auto-hiding panel use one guarded action_sequence containing its opener and target, using observed resource IDs.",
code=ActionCode.TARGET_NOT_FOUND
if isinstance(exc, LookupError)
else ActionCode.DEVICE_ERROR,
metadata={
**receipt,
"side_effect": "none"
if receipt.get("execution") == "not_started"
else "possible",
"expected_resource_id": expected_resource_id,
},
)
if error:
return ActionResult.failure("click", error, metadata=receipt)
return ActionResult.success(
"click",
f"Tapped at [{nx}, {ny}] (normalized).",
normalized_coordinates=[int(nx), int(ny)],
metadata={**receipt, "execution": "completed"},
)
result = await self.controller.tap_at(x, y, times=times, delay_ms=delay_ms)
err = getattr(result, "error", None) if result else None
if err is not None:
+7 -1
View File
@@ -64,7 +64,13 @@ class Actuator(Protocol):
# --- Optional actions ------------------------------------------------------------
async def click(
self, nx: int, ny: int, times: int = 1, delay_ms: int = 100
self,
nx: int,
ny: int,
times: int = 1,
delay_ms: int = 100,
*,
expected_resource_id: str | None = None,
) -> ActionResult: ...
async def long_press(self, nx: int, ny: int, duration_ms: int = 1000) -> ActionResult: ...
+48 -2
View File
@@ -51,6 +51,11 @@ class DragRequest(GestureModel):
calibration: DragCalibration | None = Field(
default=None, description="Computes the endpoint from observed anchors; then set end=null."
)
require_visible: str | None = Field(
default=None,
min_length=1,
description="Exact previously observed resource_id of a panel control. Check live visibility before dragging; refuse input if absent. Use for auto-hiding controls.",
)
@model_validator(mode="after")
def endpoint_or_calibration(self):
@@ -63,9 +68,24 @@ class DragRequest(GestureModel):
class ClickStep(GestureModel):
action: Literal["click"]
target: Point
target: Point | Annotated[str, Field(min_length=1)] = Field(
description="Prefer an exact observed resource_id: resolve its live bounds before tapping, refusing absent/ambiguous targets. Otherwise use [x,y] in coordinate_space; never mix screenshot pixels with normalized coordinates."
)
target_description: str = Field(min_length=1)
coordinate_space: CoordinateSpace = "normalized"
times: int = Field(
default=1,
strict=True,
ge=1,
le=10,
description="Consecutive taps within this ONE member, e.g. 10 for a scale +/- button; no model turns between taps.",
)
delay_ms: int = Field(default=100, strict=True, ge=0, le=1000)
unless_visible: str | None = Field(
default=None,
min_length=1,
description="Exact previously observed resource_id inside a toggled panel (not the opener). Skip this opener click when that control is already visible; otherwise click. Avoid closing a still-open panel.",
)
class DragStep(DragRequest):
@@ -84,6 +104,17 @@ class SequenceRequest(GestureModel):
steps: list[GestureStep] = Field(min_length=1, max_length=10)
def validate_sequence(payload: dict, limit: int) -> SequenceRequest:
steps = payload.get("steps")
if isinstance(steps, list) and len(steps) > limit:
raise ValueError(
f"Sequence has {len(steps)} members; this task allows at most {limit}, including waits. "
"No input executed. Split into complete short open/wait/operate groups; "
"do not fall back to separate model turns for auto-hiding targets."
)
return SequenceRequest.model_validate(payload)
def pixel_point(point: list[int], space: CoordinateSpace, width: int, height: int) -> list[int]:
if width <= 0 or height <= 0:
raise ValueError("Screen dimensions must be positive.")
@@ -162,6 +193,7 @@ def plan_drag(request: DragRequest, width: int, height: int) -> dict:
],
"duration_ms": request.duration_ms,
"target_description": request.target_description,
**({"require_visible": request.require_visible} if request.require_visible else {}),
**({"calibration": calibration} if calibration else {}),
}
@@ -177,9 +209,23 @@ def plan_sequence(request: SequenceRequest, width: int, height: int, limit: int)
planned.append(
{
"action": "click",
"coordinates": pixel_point(step.target, step.coordinate_space, width, height),
**(
{"target_resource_id": step.target}
if isinstance(step.target, str)
else {
"coordinates": pixel_point(
step.target, step.coordinate_space, width, height
)
}
),
"coordinate_space": "pixel",
"target_description": step.target_description,
**(
{"times": step.times, "delay_ms": step.delay_ms}
if step.times != 1 or step.delay_ms != 100
else {}
),
**({"unless_visible": step.unless_visible} if step.unless_visible else {}),
}
)
else: