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

+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,