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:
chenmingxuan committed 2026-11-02 23:20:34 +08:00
1 parent 6a69a595ef
commit 4ea7939d9c
20 files changed
+2779 -33

No files matched your search

+3
View File
@@ -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",
+27
View File
@@ -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."
)
+40 -2
View File
@@ -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. "
+55 -10
View File
@@ -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,
)
+3 -1
View File
@@ -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
View File
@@ -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 {}),
}
+47 -2
View File
@@ -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,
+42 -4
View File
@@ -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,
+284
View File
@@ -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,
},
}
)