feat(android): add state conditions and measured gesture feedback
Add optional ensure_state, gesture region and final value feedback shared by Flash and Pro. Preserve completed inputs when verification fails and stop repeated ineffective strategies. Regenerate pinned tool schemas for the new optional arguments.
This commit is contained in:
1 parent
6a69a595ef
commit
4ea7939d9c
20 files changed
+2779
-33
No files matched your search
@@ -11,6 +11,9 @@ You are an autonomous UI execution agent. Accomplish the user's objective using
|
||||
|
||||
**Objective: {{ goal }}**
|
||||
|
||||
{% if "drag" in available_tools %}- **数值目标的闭环**:先观察真实手势区域,为 drag/pinch/touch_gesture 填 `drag.region`,不要把日期栏、导航栏当作时间轴。若原生可读取持久数值,为 drag 或 action_sequence 绑定 `action_sequence.feedback`(唯一稳定 selector、value_format、target_value、tolerance);时分秒用 clock,单位为当天秒数。组合“拖动→暂停”时只在整个序列上绑定 feedback,末尾读稳定值,不能在拖动中途的浮层上核验。已验证的同一持久读数在允许误差内就是此数值条件的证据;日期、暂停等其它要求仍分别核对,不能无依据地改称误差无法判断。连续无效反馈会拒绝第三次同策略输入;放大并重新读刻度、改为足够距离或原生设值,不能靠微改坐标绕过。calibration.tolerance 给出几何误差预算;min_movement_px 只填写实际测得的起效距离,不猜固定阈值。没有可读的持久数值时保留视觉证据与不确定项,不捏造 selector 或读数。{% endif %}
|
||||
{% if "control_action" in available_tools %}- **先状态后动作**:对于暂停、勾选等目标状态,先 read_control 确认其真实原生语义,再用 control_action.ensure_state;已符合时跳过,否则只操作一次并有界核验。可按观察到的 checked/selected、text 或 content_desc 判断;不能假设 selected 就表示暂停。selector 不包含动作会改变的文本/描述。拖动可能恢复播放,把确保暂停放在拖动后同一动作序列中;状态核验失败时重新观察,不重复盲点切换。{% endif %}
|
||||
|
||||
---
|
||||
|
||||
# 1. COGNITIVE PROTOCOL
|
||||
|
||||
@@ -263,7 +263,9 @@ REPORT_TASK_STATUS_TOOL = ToolDeclaration(
|
||||
"properties": {
|
||||
"report_note_key": {
|
||||
"type": "string",
|
||||
"description": "If the requested final report is already saved with save_note, name that note here. ARTEMIS uses its exact body as the final answer and output.md, instead of generating a second conflicting report. Omit when explanation itself is the complete report.",
|
||||
"pattern": r"^[\w-]{0,128}$",
|
||||
"maxLength": 128,
|
||||
"description": "Copy the exact plain note name ONLY if the requested final report was saved with save_note. Otherwise use the empty string (or omit); never supply placeholders or paths such as /not/requested. ARTEMIS uses the saved note's exact body as the final report.",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
|
||||
@@ -12,6 +12,7 @@ class GestureModel(BaseModel):
|
||||
|
||||
|
||||
Point = Annotated[list[StrictInt], Field(min_length=2, max_length=2)]
|
||||
Region = Annotated[list[StrictInt], Field(min_length=4, max_length=4)]
|
||||
Duration = Annotated[int, Field(strict=True, ge=1, le=10000)]
|
||||
CoordinateSpace = Literal["normalized", "pixel"]
|
||||
|
||||
@@ -25,3 +26,29 @@ def pixel_point(point: list[int], space: CoordinateSpace, width: int, height: in
|
||||
if space == "pixel":
|
||||
return list(point)
|
||||
return [min(size - 1, p * size // 1000) for p, size in zip(point, [width, height], strict=True)]
|
||||
|
||||
|
||||
def pixel_region(region: list[int], space: CoordinateSpace, width: int, height: int) -> list[int]:
|
||||
"""Observed rectangle; right/bottom are exclusive, like Android bounds."""
|
||||
left, top, right, bottom = region
|
||||
max_x, max_y = (1000, 1000) if space == "normalized" else (width, height)
|
||||
if not (0 <= left < right <= max_x and 0 <= top < bottom <= max_y):
|
||||
raise ValueError(
|
||||
"region must be a nonempty on-screen [left, top, right, bottom] rectangle."
|
||||
)
|
||||
if space == "pixel":
|
||||
return list(region)
|
||||
return [
|
||||
left * width // 1000,
|
||||
top * height // 1000,
|
||||
right * width // 1000,
|
||||
bottom * height // 1000,
|
||||
]
|
||||
|
||||
|
||||
def require_in_region(points: list[list[int]], region: list[int]) -> None:
|
||||
left, top, right, bottom = region
|
||||
if any(not (left <= x < right and top <= y < bottom) for x, y in points):
|
||||
raise ValueError(
|
||||
"Gesture leaves the observed region; re-observe the actual gesture surface. No input executed."
|
||||
)
|
||||
@@ -70,6 +70,8 @@ from pydantic import BaseModel, Field, create_model
|
||||
from artemis.core.tool_declaration import ToolDeclaration
|
||||
from artemis.mcp.action_types import ActionResult
|
||||
from artemis.mcp.gestures import CoordinateSpace, DragCalibration, GestureStep
|
||||
from artemis.mcp.action_parameters import Region
|
||||
from artemis.mcp.value_feedback import ValueFeedback
|
||||
from artemis.mcp.native_actions import NATIVE_REQUESTS, READ_ONLY_ACTIONS
|
||||
|
||||
__all__ = [
|
||||
@@ -225,6 +227,8 @@ async def _wire_drag(actuator: Any, a: dict[str, Any]) -> ActionResult:
|
||||
|
||||
|
||||
async def _wire_action_sequence(actuator: Any, a: dict[str, Any]) -> ActionResult:
|
||||
if a.get("feedback") is not None:
|
||||
return await actuator.action_sequence(a["steps"], feedback=a["feedback"])
|
||||
return await actuator.action_sequence(a["steps"])
|
||||
|
||||
|
||||
@@ -298,7 +302,13 @@ _SPECS: tuple[ActionSpec, ...] = (
|
||||
"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."
|
||||
"Verify actual ticks/handle endpoints before claiming maximum or minimum. "
|
||||
"For a measurable target, bind feedback to its persistent native readout: "
|
||||
"final feedback is read AFTER ALL members, including any pause, finish. Never use a release-time tooltip. "
|
||||
"Use control_action.request.ensure_state for observed checked/selected/text/value state, "
|
||||
"so an already satisfied state is skipped rather than toggled. A playback icon alone "
|
||||
"does not prove a native boolean state: inspect it first. Feedback blocks the third "
|
||||
"consecutive ineffective strategy; zoom or increase the meaningful movement, then re-observe."
|
||||
),
|
||||
params=(
|
||||
ParamSpec(
|
||||
@@ -306,11 +316,20 @@ _SPECS: tuple[ActionSpec, ...] = (
|
||||
list[GestureStep],
|
||||
"Ordered UI preparation, click, wait, drag, pinch, touch_gesture, control_action and wait_until members. control_action members wrap their native selector/action arguments in request. Inline text is limited to 1024 characters; use a standalone control_action for long edits. Entire encoded sequence stays within the existing 16KiB argument safety bound.",
|
||||
),
|
||||
ParamSpec(
|
||||
"feedback",
|
||||
ValueFeedback | None,
|
||||
"Optional actual value/target comparison after the WHOLE sequence. clock uses seconds since midnight; exact bound and stable observation are checked locally, not inferred from dispatch.",
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
),
|
||||
wire=WireDialect(
|
||||
description="Execute validated short gesture steps consecutively; stop at the first failure.",
|
||||
params=(ParamSpec("steps", list[GestureStep]),),
|
||||
params=(
|
||||
ParamSpec("steps", list[GestureStep]),
|
||||
ParamSpec("feedback", ValueFeedback | None, required=False),
|
||||
),
|
||||
bind=_wire_action_sequence,
|
||||
),
|
||||
),
|
||||
@@ -350,6 +369,18 @@ _SPECS: tuple[ActionSpec, ...] = (
|
||||
"coordinate_space", CoordinateSpace, required=False, default="normalized"
|
||||
),
|
||||
ParamSpec("duration_ms", int, required=False, default=300),
|
||||
ParamSpec(
|
||||
"region",
|
||||
Region | None,
|
||||
"Observed gesture surface rectangle, in coordinate_space. Keep both endpoints inside.",
|
||||
required=False,
|
||||
),
|
||||
ParamSpec(
|
||||
"feedback",
|
||||
ValueFeedback | None,
|
||||
"Read the persistent actual value after the drag. For drag then pause, put feedback on action_sequence instead.",
|
||||
required=False,
|
||||
),
|
||||
ParamSpec(
|
||||
"calibration",
|
||||
DragCalibration | None,
|
||||
@@ -374,6 +405,8 @@ _SPECS: tuple[ActionSpec, ...] = (
|
||||
"coordinate_space", CoordinateSpace, required=False, default="normalized"
|
||||
),
|
||||
ParamSpec("duration_ms", int, required=False, default=300),
|
||||
ParamSpec("region", Region | None, required=False),
|
||||
ParamSpec("feedback", ValueFeedback | None, required=False),
|
||||
ParamSpec("calibration", DragCalibration | None, required=False),
|
||||
ParamSpec("require_visible", str | None, required=False),
|
||||
),
|
||||
@@ -853,6 +886,11 @@ _NATIVE_DESCRIPTIONS = {
|
||||
"control_action": (
|
||||
"[ACTION] Perform ONE supported native action on a freshly resolved unique control. "
|
||||
"Read_control first; copy selector exactly and optionally expected_token to reject stale evidence. "
|
||||
"For a desired state use ensure_state (checked/selected/text/content_desc/value), then "
|
||||
"verify_timeout_ms: skip an already satisfied state, otherwise write ONCE and poll read-only. "
|
||||
"Copy observed state semantics; do not assume selected means paused. Bind a stable selector "
|
||||
"excluding text/content_desc that the action changes. A failed postcondition does not undo "
|
||||
"the completed input and must not cause an automatic second toggle. "
|
||||
"action=set_progress requires a finite in-range value; set_selection requires selection_start/end "
|
||||
"in UTF-16 code units (never split an emoji). set_text and replace_selection require text, "
|
||||
"including empty text; whitespace is preserved. Other actions take no text/value/selection. "
|
||||
|
||||
@@ -108,6 +108,9 @@ class AdbActuator:
|
||||
):
|
||||
self.ctx = ctx
|
||||
self.controller = controller or UnifiedMobileController(ctx)
|
||||
from artemis.mcp.value_feedback import ValueFeedbackRunner
|
||||
|
||||
self._value_feedback = ValueFeedbackRunner()
|
||||
|
||||
# --- Contract --------------------------------------------------------------------
|
||||
|
||||
@@ -183,6 +186,8 @@ class AdbActuator:
|
||||
else "possible"
|
||||
)
|
||||
if receipt.get("success"):
|
||||
if name in {"pinch", "touch_gesture"} and receipt.get("execution") == "completed":
|
||||
self._value_feedback.history.clear()
|
||||
return ActionResult.success(
|
||||
name,
|
||||
"Native operation completed. Inspect the receipt and current app state; business verification remains separate.",
|
||||
@@ -224,10 +229,24 @@ class AdbActuator:
|
||||
from artemis.mcp.gestures import DragRequest, plan_drag
|
||||
|
||||
try:
|
||||
planned = plan_drag(DragRequest.model_validate(kwargs), *self._dims())
|
||||
request = DragRequest.model_validate(kwargs)
|
||||
planned = plan_drag(request, *self._dims())
|
||||
except ValueError as exc:
|
||||
return ActionResult.failure("drag", str(exc), code=ActionCode.INVALID_ARGS)
|
||||
return await self._dispatch_drag(planned)
|
||||
if request.feedback is not None and "read_control" not in self.capabilities():
|
||||
return ActionResult.failure(
|
||||
"drag",
|
||||
"Value feedback requires native read_control; no input executed.",
|
||||
code=ActionCode.UNSUPPORTED,
|
||||
metadata={"execution": "not_started", "side_effect": "none"},
|
||||
)
|
||||
return await self._value_feedback.run(
|
||||
request.feedback,
|
||||
[planned],
|
||||
"drag",
|
||||
self.controller.native_request,
|
||||
lambda: self._dispatch_drag(planned),
|
||||
)
|
||||
|
||||
async def _dispatch_drag(self, planned: dict) -> ActionResult:
|
||||
metadata = dict(planned)
|
||||
@@ -374,19 +393,18 @@ class AdbActuator:
|
||||
f"Panel target {step['target']} did not become visible within {step['timeout_ms']}ms after one opener tap. No opener retry; re-observe before continuing."
|
||||
) from exc
|
||||
|
||||
async def action_sequence(self, steps: list[Any]) -> ActionResult:
|
||||
async def action_sequence(
|
||||
self, steps: list[Any], feedback: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""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 plan_sequence, validate_sequence
|
||||
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||
|
||||
try:
|
||||
limit = load_agent_config().pro.execution.max_burst_actions
|
||||
request = validate_sequence({"steps": steps, "feedback": feedback}, limit)
|
||||
planned = plan_sequence(
|
||||
validate_sequence({"steps": steps}, limit),
|
||||
request,
|
||||
*self._dims(),
|
||||
limit=limit,
|
||||
)
|
||||
@@ -406,6 +424,28 @@ class AdbActuator:
|
||||
code=ActionCode.UNSUPPORTED,
|
||||
metadata={"execution": "not_started", "side_effect": "none"},
|
||||
)
|
||||
if request.feedback is not None and "read_control" not in self.capabilities():
|
||||
return ActionResult.failure(
|
||||
"action_sequence",
|
||||
"Value feedback requires native read_control; no input executed.",
|
||||
code=ActionCode.UNSUPPORTED,
|
||||
metadata={"execution": "not_started", "side_effect": "none"},
|
||||
)
|
||||
return await self._value_feedback.run(
|
||||
request.feedback,
|
||||
planned,
|
||||
"action_sequence",
|
||||
self.controller.native_request,
|
||||
lambda: self._execute_sequence(planned),
|
||||
)
|
||||
|
||||
async def _execute_sequence(self, planned: list[dict]) -> ActionResult:
|
||||
import time
|
||||
|
||||
from artemis.data_engine.trace import TraceSpan
|
||||
from artemis.mcp.native_actions import NATIVE_ACTIONS
|
||||
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||
|
||||
started = time.monotonic()
|
||||
receipts = [
|
||||
{**step, "index": i + 1, "execution": "not_started"} for i, step in enumerate(planned)
|
||||
@@ -484,8 +524,12 @@ class AdbActuator:
|
||||
error = None if result.ok else result.message
|
||||
receipt.update(result.metadata)
|
||||
receipt["native_execution"] = result.metadata.get("execution", "unknown")
|
||||
member_execution = (
|
||||
"skipped"
|
||||
if result.ok and result.metadata.get("skipped")
|
||||
else receipt["native_execution"]
|
||||
)
|
||||
if not result.ok:
|
||||
member_execution = result.metadata.get("execution", "unknown")
|
||||
failed_code = result.code
|
||||
elif step["action"] == "drag":
|
||||
result = await self._dispatch_drag(step)
|
||||
@@ -503,6 +547,7 @@ class AdbActuator:
|
||||
member_execution
|
||||
if member_execution
|
||||
in {"skipped", "not_started", "cancelled", "observed"}
|
||||
or receipt.get("native_execution") == "completed"
|
||||
or not error
|
||||
else "unknown"
|
||||
),
|
||||
@@ -553,7 +598,7 @@ class AdbActuator:
|
||||
)
|
||||
return ActionResult.success(
|
||||
"action_sequence",
|
||||
f"Executed {completed} gesture steps consecutively; skipped {skipped} unnecessary UI-opening steps. Verify the intended result in the final screen.",
|
||||
f"Executed {completed} gesture steps consecutively; skipped {skipped} already satisfied steps. Verify the intended result in the final screen.",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -105,7 +105,9 @@ class Actuator(Protocol):
|
||||
|
||||
async def scroll_to(self, **kwargs: Any) -> ActionResult: ...
|
||||
|
||||
async def action_sequence(self, steps: list[Any]) -> ActionResult: ...
|
||||
async def action_sequence(
|
||||
self, steps: list[Any], feedback: dict[str, Any] | None = None
|
||||
) -> ActionResult: ...
|
||||
|
||||
async def manage_app(self, action: str, app_name: str) -> ActionResult: ...
|
||||
|
||||
|
||||
+42
-3
@@ -14,7 +14,10 @@ from artemis.mcp.action_parameters import (
|
||||
Duration,
|
||||
GestureModel,
|
||||
Point,
|
||||
Region,
|
||||
pixel_point,
|
||||
pixel_region,
|
||||
require_in_region,
|
||||
)
|
||||
from artemis.mcp.native_actions import (
|
||||
ControlActionRequest,
|
||||
@@ -24,6 +27,7 @@ from artemis.mcp.native_actions import (
|
||||
plan_pinch,
|
||||
plan_touch,
|
||||
)
|
||||
from artemis.mcp.value_feedback import ValueFeedback
|
||||
|
||||
|
||||
class ScaleAnchor(GestureModel):
|
||||
@@ -43,9 +47,19 @@ class DragCalibration(GestureModel):
|
||||
indicator_position: float | None = Field(
|
||||
default=None, description="Required for pan: the fixed indicator's observed axis position."
|
||||
)
|
||||
tolerance: float | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Allowed value error. Reject a plan whose pixel rounding exceeds it; this is geometric error only, not proof of application precision.",
|
||||
)
|
||||
min_movement_px: float | None = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="Previously measured minimum effective drag distance in pixels. If this calibrated move is smaller, refuse input and zoom first. Never guess a device-wide threshold.",
|
||||
)
|
||||
|
||||
|
||||
class DragRequest(GestureModel):
|
||||
class DragGeometry(GestureModel):
|
||||
start: Point
|
||||
target_description: str = Field(min_length=1)
|
||||
end: Point | None = Field(
|
||||
@@ -53,6 +67,10 @@ class DragRequest(GestureModel):
|
||||
)
|
||||
coordinate_space: CoordinateSpace = "normalized"
|
||||
duration_ms: Duration = 300
|
||||
region: Region | None = Field(
|
||||
default=None,
|
||||
description="Observed gesture surface rectangle in coordinate_space, excluding date/navigation rails and unrelated overlays.",
|
||||
)
|
||||
calibration: DragCalibration | None = Field(
|
||||
default=None, description="Computes the endpoint from observed anchors; then set end=null."
|
||||
)
|
||||
@@ -72,6 +90,10 @@ class DragRequest(GestureModel):
|
||||
return self
|
||||
|
||||
|
||||
class DragRequest(DragGeometry):
|
||||
feedback: ValueFeedback | None = None
|
||||
|
||||
|
||||
class ClickStep(GestureModel):
|
||||
action: Literal["click"]
|
||||
target: Point | Annotated[str, Field(min_length=1, max_length=512)] = Field(
|
||||
@@ -95,7 +117,7 @@ class ClickStep(GestureModel):
|
||||
)
|
||||
|
||||
|
||||
class DragStep(DragRequest):
|
||||
class DragStep(DragGeometry):
|
||||
action: Literal["drag"]
|
||||
|
||||
|
||||
@@ -170,6 +192,7 @@ GestureStep = (
|
||||
|
||||
class SequenceRequest(GestureModel):
|
||||
steps: list[GestureStep] = Field(min_length=1, max_length=10)
|
||||
feedback: ValueFeedback | None = None
|
||||
|
||||
|
||||
def validate_sequence(payload: dict, limit: int) -> SequenceRequest:
|
||||
@@ -183,7 +206,7 @@ def validate_sequence(payload: dict, limit: int) -> SequenceRequest:
|
||||
return SequenceRequest.model_validate(payload)
|
||||
|
||||
|
||||
def plan_drag(request: DragRequest, width: int, height: int) -> dict:
|
||||
def plan_drag(request: DragGeometry, width: int, height: int) -> dict:
|
||||
start = pixel_point(request.start, request.coordinate_space, width, height)
|
||||
calibration = None
|
||||
if request.calibration is None:
|
||||
@@ -237,8 +260,23 @@ def plan_drag(request: DragRequest, width: int, height: int) -> dict:
|
||||
"rounding_error_value": abs(end[axis] - endpoint) * abs(slope),
|
||||
"verification": "pending_observation",
|
||||
}
|
||||
calibration["movement_px"] = abs(end[axis] - start[axis])
|
||||
if scale.tolerance is not None:
|
||||
calibration["pixel_error_budget"] = scale.tolerance / abs(slope)
|
||||
if calibration["rounding_error_value"] > scale.tolerance:
|
||||
raise ValueError(
|
||||
"Pixel rounding exceeds the requested tolerance; zoom and re-observe anchors before input."
|
||||
)
|
||||
if scale.min_movement_px is not None and calibration["movement_px"] < scale.min_movement_px:
|
||||
raise ValueError(
|
||||
"Planned movement is below the observed minimum effective distance; zoom and re-observe anchors. No input executed."
|
||||
)
|
||||
if start == end:
|
||||
raise ValueError("Drag has no pixel movement; zoom further or choose a different endpoint.")
|
||||
region = None
|
||||
if request.region is not None:
|
||||
region = pixel_region(request.region, request.coordinate_space, width, height)
|
||||
require_in_region([start, end], region)
|
||||
return {
|
||||
"action": "drag",
|
||||
"coordinates": [*start, *end],
|
||||
@@ -250,6 +288,7 @@ def plan_drag(request: DragRequest, width: int, height: int) -> dict:
|
||||
],
|
||||
"duration_ms": request.duration_ms,
|
||||
"target_description": request.target_description,
|
||||
**({"region": region} if region is not None else {}),
|
||||
**({"require_visible": request.require_visible} if request.require_visible else {}),
|
||||
**({"calibration": calibration} if calibration else {}),
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ from artemis.mcp.action_parameters import (
|
||||
Duration,
|
||||
GestureModel,
|
||||
Point,
|
||||
Region,
|
||||
pixel_point,
|
||||
pixel_region,
|
||||
require_in_region,
|
||||
)
|
||||
|
||||
ShortText = Annotated[str, Field(min_length=1, max_length=512)]
|
||||
@@ -71,6 +74,11 @@ class ControlActionRequest(ReadControlRequest):
|
||||
text: Annotated[str, StringConstraints(strip_whitespace=False, max_length=65536)] | None = None
|
||||
selection_start: Annotated[int, Field(strict=True, ge=0)] | None = None
|
||||
selection_end: Annotated[int, Field(strict=True, ge=0)] | None = None
|
||||
ensure_state: "ControlCondition | None" = Field(
|
||||
default=None,
|
||||
description="Desired observed state: skip an already satisfied action; otherwise write ONCE and verify by read-only polling. Never infer state from an icon or missing attributes.",
|
||||
)
|
||||
verify_timeout_ms: Annotated[int, Field(strict=True, ge=1, le=3000)] = 1500
|
||||
|
||||
@model_validator(mode="after")
|
||||
def action_arguments(self):
|
||||
@@ -106,6 +114,10 @@ class TouchGestureRequest(GestureModel):
|
||||
coordinate_space: CoordinateSpace = "normalized"
|
||||
target_description: ShortText
|
||||
require_visible: ShortText | None = None
|
||||
region: Region | None = Field(
|
||||
default=None,
|
||||
description="Observed gesture surface bounds in coordinate_space; every finger path must stay inside it.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def continuous_timeline(self):
|
||||
@@ -130,6 +142,10 @@ class PinchRequest(GestureModel):
|
||||
coordinate_space: CoordinateSpace = "normalized"
|
||||
target_description: ShortText
|
||||
require_visible: ShortText | None = None
|
||||
region: Region | None = Field(
|
||||
default=None,
|
||||
description="Observed zoom surface bounds in coordinate_space; not the whole screen or date rail. Both fingers must remain inside.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def meaningful_pinch(self):
|
||||
@@ -149,6 +165,7 @@ class WaitUntilRequest(ReadControlRequest):
|
||||
"selected",
|
||||
"unselected",
|
||||
"text",
|
||||
"content_desc",
|
||||
"value",
|
||||
"stable",
|
||||
]
|
||||
@@ -163,8 +180,8 @@ class WaitUntilRequest(ReadControlRequest):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def expected_arguments(self):
|
||||
if (self.expected_text is not None) != (self.state == "text"):
|
||||
raise ValueError("Only state=text requires expected_text (exact match).")
|
||||
if (self.expected_text is not None) != (self.state in {"text", "content_desc"}):
|
||||
raise ValueError("Only state=text/content_desc requires expected_text (exact match).")
|
||||
if (self.expected_value is not None) != (self.state == "value"):
|
||||
raise ValueError("Only state=value requires expected_value.")
|
||||
if self.state != "value" and self.tolerance != 0:
|
||||
@@ -174,6 +191,26 @@ class WaitUntilRequest(ReadControlRequest):
|
||||
return self
|
||||
|
||||
|
||||
class ControlCondition(GestureModel):
|
||||
state: Literal[
|
||||
"checked", "unchecked", "selected", "unselected", "text", "content_desc", "value"
|
||||
]
|
||||
expected_text: (
|
||||
Annotated[str, StringConstraints(strip_whitespace=False, max_length=4096)] | None
|
||||
) = None
|
||||
expected_value: float | None = None
|
||||
tolerance: Annotated[float, Field(ge=0)] = 0
|
||||
|
||||
@model_validator(mode="after")
|
||||
def expected_arguments(self):
|
||||
# Reuse the wait predicate contract without duplicating its semantics.
|
||||
WaitUntilRequest(selector={"class_name": "condition"}, **self.model_dump())
|
||||
return self
|
||||
|
||||
|
||||
ControlActionRequest.model_rebuild()
|
||||
|
||||
|
||||
class ScrollToRequest(GestureModel):
|
||||
container: ControlSelector
|
||||
target: ControlSelector
|
||||
@@ -222,6 +259,10 @@ def plan_touch(request: TouchGestureRequest, width: int, height: int) -> dict:
|
||||
}
|
||||
for frame in request.frames
|
||||
]
|
||||
region = None
|
||||
if request.region is not None:
|
||||
region = pixel_region(request.region, request.coordinate_space, width, height)
|
||||
require_in_region([p for frame in frames for p in frame["points"]], region)
|
||||
# Reject trajectories that merge two fingers, including between keyframes.
|
||||
for before, after in zip(frames, frames[1:]):
|
||||
for first in range(len(before["points"])):
|
||||
@@ -240,6 +281,7 @@ def plan_touch(request: TouchGestureRequest, width: int, height: int) -> dict:
|
||||
"coordinate_space": "pixel",
|
||||
"screen_size": [width, height],
|
||||
"target_description": request.target_description,
|
||||
**({"region": region} if region is not None else {}),
|
||||
**({"require_visible": request.require_visible} if request.require_visible else {}),
|
||||
}
|
||||
|
||||
@@ -271,6 +313,9 @@ def plan_pinch(request: PinchRequest, width: int, height: int) -> dict:
|
||||
coordinate_space="pixel",
|
||||
target_description=request.target_description,
|
||||
require_visible=request.require_visible,
|
||||
region=pixel_region(request.region, request.coordinate_space, width, height)
|
||||
if request.region is not None
|
||||
else None,
|
||||
),
|
||||
width,
|
||||
height,
|
||||
|
||||
@@ -158,10 +158,10 @@ async def _pause(deadline: float, interval: float) -> None:
|
||||
|
||||
def _condition(request: WaitUntilRequest, node: dict) -> bool:
|
||||
state = request.state
|
||||
if state == "text":
|
||||
if node.get("password") is True or not isinstance(node.get("text"), str):
|
||||
if state in {"text", "content_desc"}:
|
||||
if node.get("password") is True or not isinstance(node.get(state), str):
|
||||
raise ValueError("An exact readable native text value is required.")
|
||||
return node["text"] == request.expected_text
|
||||
return node[state] == request.expected_text
|
||||
if state == "value":
|
||||
native_range = _range(node)
|
||||
if native_range is None or request.expected_value is None:
|
||||
@@ -268,6 +268,23 @@ async def _control(request: ControlActionRequest, native: NativeCaller) -> dict:
|
||||
if not inspection["success"]:
|
||||
return _result("control_action", inspection)
|
||||
node = inspection["node"]
|
||||
condition = None
|
||||
if request.ensure_state is not None:
|
||||
condition = WaitUntilRequest(
|
||||
selector=request.selector,
|
||||
timeout_ms=request.verify_timeout_ms,
|
||||
**request.ensure_state.model_dump(),
|
||||
)
|
||||
try:
|
||||
satisfied = _condition(condition, node)
|
||||
except ValueError as exc:
|
||||
return _result(
|
||||
"control_action", _failure("INVALID_STATE", str(exc)), preflight=inspection
|
||||
)
|
||||
if satisfied:
|
||||
return _result(
|
||||
"control_action", inspection, skipped=True, verification="condition_satisfied"
|
||||
)
|
||||
invalid = _write_guard(node, request.action, request.expected_token)
|
||||
if invalid is None and request.action == "set_progress":
|
||||
native_range = _range(node)
|
||||
@@ -284,9 +301,30 @@ async def _control(request: ControlActionRequest, native: NativeCaller) -> dict:
|
||||
)
|
||||
if invalid is not None:
|
||||
return _result("control_action", invalid, preflight=inspection, node_before=node)
|
||||
args = request.model_dump(mode="json", exclude_none=True)
|
||||
args = request.model_dump(
|
||||
mode="json", exclude_none=True, exclude={"ensure_state", "verify_timeout_ms"}
|
||||
)
|
||||
args["expected_token"] = node["token"]
|
||||
receipt = await _call(native, "control_action", args)
|
||||
if receipt["success"] and condition is not None:
|
||||
verification = await _wait(condition, native)
|
||||
outcome = dict(receipt)
|
||||
if not verification["success"]:
|
||||
outcome.update(
|
||||
success=False,
|
||||
code=verification["code"],
|
||||
error="The input completed, but its requested state was not verified. Do not toggle again blindly.",
|
||||
)
|
||||
return _result(
|
||||
"control_action",
|
||||
outcome,
|
||||
native_receipt=receipt,
|
||||
preflight=inspection,
|
||||
postcondition=verification,
|
||||
node_before=node,
|
||||
node_after=verification.get("node"),
|
||||
verification="condition_satisfied" if verification["success"] else "condition_not_met",
|
||||
)
|
||||
return _result(
|
||||
"control_action",
|
||||
receipt,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Optional, task-local feedback for numeric adjustments; never retries an input."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from asyncio import sleep
|
||||
from time import monotonic
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from artemis.mcp.action_parameters import GestureModel
|
||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||
from artemis.mcp.native_actions import ControlSelector
|
||||
from artemis.mcp.native_executor import NativeCaller, execute_native
|
||||
|
||||
|
||||
class ValueFeedback(GestureModel):
|
||||
selector: ControlSelector = Field(
|
||||
description="Unique, observed persistent value readout, not a transient drag tooltip. Copy its stable selector, excluding changing text."
|
||||
)
|
||||
value_format: Literal["range", "number", "clock"] = Field(
|
||||
description="range reads native range.current; number requires an entire numeric text; clock requires exactly one HH:MM:SS in readable text and returns seconds since midnight."
|
||||
)
|
||||
target_value: float
|
||||
tolerance: Annotated[float, Field(ge=0)]
|
||||
stable_ms: Annotated[int, Field(strict=True, ge=100, le=5000)] = 1000
|
||||
timeout_ms: Annotated[int, Field(strict=True, ge=200, le=10000)] = 3000
|
||||
|
||||
@model_validator(mode="after")
|
||||
def bounded_observation(self):
|
||||
if self.stable_ms >= self.timeout_ms:
|
||||
raise ValueError("stable_ms must be smaller than timeout_ms.")
|
||||
return self
|
||||
|
||||
|
||||
def read_value(node: dict, value_format: str) -> float:
|
||||
if node.get("password") is True or node.get("visible") is not True:
|
||||
raise ValueError("The value must be visible, readable and non-password.")
|
||||
if value_format == "range":
|
||||
native_range = node.get("range")
|
||||
if not isinstance(native_range, dict):
|
||||
raise ValueError("No native numeric range; do not guess a value from the gesture.")
|
||||
values = [native_range.get(k) for k in ("min", "current", "max")]
|
||||
if any(type(v) not in (int, float) or not math.isfinite(v) for v in values):
|
||||
raise ValueError("Invalid native numeric range.")
|
||||
low, current, high = values
|
||||
if not low <= current <= high:
|
||||
raise ValueError("Invalid native numeric range bounds.")
|
||||
return float(current)
|
||||
text = node.get("text")
|
||||
if not isinstance(text, str) or len(text) > 4096:
|
||||
raise ValueError("The selected node has no bounded readable text.")
|
||||
if value_format == "clock":
|
||||
matches = re.findall(r"(?<![\d:])([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?![\d:])", text)
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
"clock requires exactly one unambiguous HH:MM:SS; select its persistent readout."
|
||||
)
|
||||
h, m, s = map(int, matches[0])
|
||||
return float(h * 3600 + m * 60 + s)
|
||||
if not re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", text.strip()):
|
||||
raise ValueError("number requires an entire numeric text, without units or other numbers.")
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("The observed value must be finite.")
|
||||
return value
|
||||
|
||||
|
||||
def strategy_key(planned: list[dict]) -> str:
|
||||
"""Ignore tiny coordinate jitter; a new scale, axis or meaningful span is a new strategy."""
|
||||
strategy = []
|
||||
for step in planned:
|
||||
if step["action"] == "drag":
|
||||
x1, y1, x2, y2 = step["coordinates"]
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
calibration = step.get("calibration", {})
|
||||
strategy.append(
|
||||
[
|
||||
"drag",
|
||||
"x" if abs(dx) >= abs(dy) else "y",
|
||||
1 if (dx if abs(dx) >= abs(dy) else dy) > 0 else -1,
|
||||
math.hypot(dx, dy),
|
||||
step["duration_ms"],
|
||||
round(calibration.get("value_per_pixel", 0), 8),
|
||||
step.get("region"),
|
||||
]
|
||||
)
|
||||
else:
|
||||
strategy.append(step)
|
||||
return json.dumps(strategy, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def same_strategy(left: str, right: str) -> bool:
|
||||
"""Compare relative changes; crossing an arbitrary pixel bucket is not a new strategy."""
|
||||
before, after = json.loads(left), json.loads(right)
|
||||
if len(before) != len(after):
|
||||
return False
|
||||
for old, new in zip(before, after, strict=True):
|
||||
if isinstance(old, list) and isinstance(new, list) and old[0] == new[0] == "drag":
|
||||
if old[1:3] != new[1:3] or old[6] != new[6]:
|
||||
return False
|
||||
if any(not 0.5 < new[i] / old[i] < 2 for i in (3, 4)):
|
||||
return False
|
||||
if not math.isclose(old[5], new[5], rel_tol=0.05):
|
||||
return False
|
||||
elif old != new:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ValueFeedbackRunner:
|
||||
"""Bounded memory owned by one device actuator/task, never persisted or shared."""
|
||||
|
||||
def __init__(self):
|
||||
self.history: dict[str, tuple[str, float, int]] = {}
|
||||
|
||||
async def _observe(
|
||||
self, request: ValueFeedback, native: NativeCaller, *, stable: bool
|
||||
) -> tuple[float, dict]:
|
||||
deadline = monotonic() + request.timeout_ms / 1000
|
||||
previous = None
|
||||
since = monotonic()
|
||||
samples = 0
|
||||
async with asyncio.timeout(request.timeout_ms / 1000):
|
||||
while monotonic() < deadline:
|
||||
receipt = await execute_native(
|
||||
"read_control",
|
||||
{"selector": request.selector.model_dump(exclude_none=True)},
|
||||
1,
|
||||
1,
|
||||
native,
|
||||
)
|
||||
if monotonic() >= deadline:
|
||||
raise TimeoutError("The value observation arrived after the feedback deadline.")
|
||||
if not receipt["success"]:
|
||||
if stable and receipt.get("code") in {
|
||||
"NOT_FOUND",
|
||||
"READ_FAILED",
|
||||
"UNAVAILABLE",
|
||||
}:
|
||||
previous = None
|
||||
since = monotonic()
|
||||
await sleep(min(0.1, max(0, deadline - monotonic())))
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Value read failed: {receipt.get('code')}; no value inferred."
|
||||
)
|
||||
value = read_value(receipt["node"], request.value_format)
|
||||
samples += 1
|
||||
evidence = {
|
||||
"node": receipt["node"],
|
||||
"samples": samples,
|
||||
"stable_for_ms": round((monotonic() - since) * 1000)
|
||||
if stable and value == previous
|
||||
else 0,
|
||||
}
|
||||
if not stable:
|
||||
return value, evidence
|
||||
if value != previous:
|
||||
previous, since = value, monotonic()
|
||||
elif monotonic() - since >= request.stable_ms / 1000:
|
||||
return value, evidence
|
||||
await sleep(min(0.1, max(0, deadline - monotonic())))
|
||||
raise TimeoutError("The value did not settle within the feedback deadline.")
|
||||
|
||||
async def run(
|
||||
self,
|
||||
request: ValueFeedback | None,
|
||||
planned: list[dict],
|
||||
action: str,
|
||||
native: NativeCaller,
|
||||
operation: Callable[[], Awaitable[ActionResult]],
|
||||
) -> ActionResult:
|
||||
if request is None:
|
||||
return await operation()
|
||||
key = json.dumps(
|
||||
[
|
||||
request.selector.model_dump(),
|
||||
request.value_format,
|
||||
request.target_value,
|
||||
request.tolerance,
|
||||
],
|
||||
sort_keys=True,
|
||||
)
|
||||
strategy = strategy_key(planned)
|
||||
try:
|
||||
before, before_observation = await self._observe(request, native, stable=False)
|
||||
except (ValueError, TimeoutError) as exc:
|
||||
self.history.pop(key, None)
|
||||
return ActionResult.failure(
|
||||
action,
|
||||
f"Feedback unavailable; no input executed: {exc}",
|
||||
code=ActionCode.TARGET_NOT_FOUND,
|
||||
metadata={
|
||||
"execution": "not_started",
|
||||
"side_effect": "none",
|
||||
"verification": "unavailable",
|
||||
},
|
||||
)
|
||||
previous = self.history.get(key)
|
||||
stalled = (
|
||||
previous[2]
|
||||
if previous and previous[1] == before and same_strategy(previous[0], strategy)
|
||||
else 0
|
||||
)
|
||||
feedback = {
|
||||
"selector": request.selector.model_dump(exclude_none=True),
|
||||
"before_value": before,
|
||||
"before_observation": before_observation,
|
||||
"target_value": request.target_value,
|
||||
"tolerance": request.tolerance,
|
||||
"value_format": request.value_format,
|
||||
}
|
||||
if stalled >= 2:
|
||||
return ActionResult.failure(
|
||||
action,
|
||||
"NO_PROGRESS: two adjustments did not reduce the actual residual. No further input executed. Re-observe the gesture surface and scale; zoom, change the meaningful drag span or use a native setter. Do not repeat the same small drag.",
|
||||
metadata={
|
||||
"execution": "not_started",
|
||||
"side_effect": "none",
|
||||
"verification": "strategy_change_required",
|
||||
"value_feedback": {**feedback, "consecutive_no_progress": stalled},
|
||||
},
|
||||
)
|
||||
result = await operation()
|
||||
if not result.ok:
|
||||
self.history.pop(key, None)
|
||||
return result
|
||||
try:
|
||||
after, after_observation = await self._observe(request, native, stable=True)
|
||||
except (ValueError, TimeoutError) as exc:
|
||||
self.history.pop(key, None)
|
||||
return result.model_copy(
|
||||
update={
|
||||
"ok": False,
|
||||
"code": ActionCode.TIMEOUT
|
||||
if isinstance(exc, TimeoutError)
|
||||
else ActionCode.TARGET_NOT_FOUND,
|
||||
"message": f"Input completed; value feedback unavailable: {exc}. Do not replay the input blindly.",
|
||||
"metadata": {
|
||||
**result.metadata,
|
||||
"execution": "completed",
|
||||
"verification": "unavailable",
|
||||
"value_feedback": feedback,
|
||||
},
|
||||
}
|
||||
)
|
||||
residual = after - request.target_value
|
||||
matched = abs(residual) <= request.tolerance
|
||||
improved = abs(residual) < abs(before - request.target_value) - 1e-9
|
||||
stalled = 0 if matched or improved else stalled + 1
|
||||
if len(self.history) >= 32:
|
||||
self.history.pop(next(iter(self.history)))
|
||||
self.history[key] = (strategy, after, stalled)
|
||||
feedback.update(
|
||||
after_observation=after_observation,
|
||||
after_value=after,
|
||||
residual=residual,
|
||||
changed=after != before,
|
||||
improved=improved,
|
||||
within_tolerance=matched,
|
||||
consecutive_no_progress=stalled,
|
||||
)
|
||||
return result.model_copy(
|
||||
update={
|
||||
"ok": matched,
|
||||
"code": ActionCode.OK if matched else ActionCode.DEVICE_ERROR,
|
||||
"message": f"Input completed. Observed value={after:g}, target={request.target_value:g}, residual={residual:g}, tolerance={request.tolerance:g}. "
|
||||
+ (
|
||||
"Value condition met; complete task acceptance separately."
|
||||
if matched
|
||||
else "Value condition not met. Use this observed residual to change strategy; never infer success from dispatch."
|
||||
),
|
||||
"metadata": {
|
||||
**result.metadata,
|
||||
"execution": "completed",
|
||||
"verification": "value_matched" if matched else "value_not_reached",
|
||||
"value_feedback": feedback,
|
||||
},
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user