feat: add gesture sequences and calibrated pixel drags

This commit is contained in:
chenmingxuan committed 2026-11-02 11:53:27 +08:00
1 parent 2a3bbaa3fb
commit 479a185192
31 files changed
+2440 -44

No files matched your search

+2
View File
@@ -30,6 +30,8 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
- **Time-Sensitive Tasks & Transient UI**: Because of the turn latency above, a target element that you see on the current screenshot (e.g., auto-dismissing overlays, temporary message bars, menus or control bars with short timeouts) may have already vanished by the time your physical click command reaches the device! This is one of the most common reasons why the UI does not change as expected after a single step. We call these **Time-Sensitive Tasks**.
- *Mental Model*: When facing these tasks, any attempt to interact step-by-step (such as tapping once to wake up a menu/bar and waiting for the next turn to tap the target button) WILL fail because the element will auto-dismiss during your decision delay. You MUST combine the summon action on the trigger element (`Trigger`) and the target action (`Target`) into a single atomic sequence.
{% if "click_sequence" in available_tools %} - *Chained Execution*: Use `click_sequence(sequence=[[trigger_x, trigger_y], [target_x, target_y]])` in `0-1000` normalized scale to execute both taps consecutively with a 50ms delay, catching the transient element before the timeout closes it.{% endif %}
{% if "action_sequence" in available_tools %} - **短时控件的混合动作**:需要打开后拖动时,优先一次调用 `action_sequence`,将 click、短暂 wait、drag 放入 steps,不分成几轮模型决策。后续目标须来自同布局的真实观察,不能猜测。逐项读取回执;部分执行后不得整组重发,先观察当前状态。{% endif %}
{% if "drag" in available_tools %} - **刻度校准**:局部滑条、时间轴优先用 `drag`。放大后可用当前原图的 pixel 坐标,或两个真实刻度 anchors 计算目标;时间统一换算成秒等同一数值单位。pan 用固定指针 indicator_position,thumb 用真实滑块起点。不要自动越过终点,也不要一味拖慢;短距离可从 300ms 起步。动作后比较同名刻度或真实选中值,仅根据已观察残差修正。回执中的 calibration 是计算/派发信息,不能当作已选中目标值;精度不足先放大。{% endif %}
---
+22 -2
View File
@@ -253,7 +253,14 @@ class FlashRunner:
t
for t in tools
if t.name not in DEVICE_ACTIONS
and t.name not in {"ask_explorer", "video_analyzer", "click_sequence"}
and t.name
not in {
"ask_explorer",
"video_analyzer",
"click_sequence",
"action_sequence",
"drag",
}
]
# With an actuator installed, drop declarations for device actions the
# backend does not implement (and append its extension tools); without
@@ -781,7 +788,20 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
action_dict.update(metadata.get("target_semantics") or {})
for key in ("input_verification", "execution", "side_effect", "source"):
for key in (
"input_verification",
"execution",
"side_effect",
"source",
"sequence_results",
"completed_steps",
"calibration",
"coordinate_space",
"coordinates",
"duration_ms",
"screen_size",
"normalized_coordinates",
):
if key in metadata:
action_dict[key] = metadata[key]
if metadata.get("plugin_id"):
+1 -1
View File
@@ -1,3 +1,3 @@
{
"main_template": "# 输出语言要求\n所有自行撰写的自然语言说明必须使用简体中文,包括每一步的操作说明、计划与子任务、检查反馈、诊断结论、历史摘要和最终报告。英文示例仅示范格式,不决定输出语言。\n保留工具名、参数名、JSON 字段名、枚举值、状态码、计划语法标记、模板变量、命令、路径、URL、包名及标识符原文。界面文字、日志、错误信息、引用证据和用户指定的输入内容必须如实保留,可另附中文解释;不要为了中文化而翻译设备输入、搜索词或精确匹配目标。\n遵守各自原有输出结构;结构化结果只将允许自由填写的说明文本写成简体中文。只描述已有证据,不编造操作结果。\n\n每次调用设备操作工具前,用一小段简体中文说明当前观察、即将执行的动作和目的;操作后仅在获得结果或新观察时说明实际变化。不要只在最终答复中使用中文。工具参数中的自由描述文本也使用简体中文,精确引用的界面文字仍保留原文。\n\n# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **视觉问题复核**:报告遮挡/布局缺陷前,对照同来源、同内容的前后截图及相邻步骤,引用触发动作与实际影响,遵守用户明确允许的行为。正文滚入固定导航后方或出现背景模糊不单独证明缺陷;之前可读、可到达的内容不能仅因后续滚动而判为不可访问。持续阻碍阅读/操作或违反用户明确视觉标准时才确认相应问题。低对比度观感不能直接推断点击失效或不满足未指定的量化阈值;区分已确认问题、待确认风险和未测项。优先复用已有证据,仅补缺少的观察,不为消除疑点无限重跑。\n- **要求与证据**:沿用现有任务计划,将用户明确要求的前置条件、开关状态、检查动作和交付物列为必要项,区分待检查、已有证据、受阻。仅在状态变化时简短记下要求、来源/步骤和实际观察,不每轮重抄清单。跨端切换前和结束前核对必要项及最新用户指导,只补尚缺且可安全取得的证据;无法补齐时明确列出缺项。用户希望发生、动作已发出或计划已勾选都不证明验收通过。\n- **证据冲突**:关键文字、数值或开关状态有冲突/不清晰时,回查对应步骤原图或控件树,保留来源和不确定性,不按常识或多数摘要猜测。手机与电脑证据分别归属,仅在同条件观察支持时报告跨端差异。保持已有可靠证据,只回查缺失或矛盾部分。\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, soLine truncated
"main_template": "# 输出语言要求\n所有自行撰写的自然语言说明必须使用简体中文,包括每一步的操作说明、计划与子任务、检查反馈、诊断结论、历史摘要和最终报告。英文示例仅示范格式,不决定输出语言。\n保留工具名、参数名、JSON 字段名、枚举值、状态码、计划语法标记、模板变量、命令、路径、URL、包名及标识符原文。界面文字、日志、错误信息、引用证据和用户指定的输入内容必须如实保留,可另附中文解释;不要为了中文化而翻译设备输入、搜索词或精确匹配目标。\n遵守各自原有输出结构;结构化结果只将允许自由填写的说明文本写成简体中文。只描述已有证据,不编造操作结果。\n\n每次调用设备操作工具前,用一小段简体中文说明当前观察、即将执行的动作和目的;操作后仅在获得结果或新观察时说明实际变化。不要只在最终答复中使用中文。工具参数中的自由描述文本也使用简体中文,精确引用的界面文字仍保留原文。\n\n# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **视觉问题复核**:报告遮挡/布局缺陷前,对照同来源、同内容的前后截图及相邻步骤,引用触发动作与实际影响,遵守用户明确允许的行为。正文滚入固定导航后方或出现背景模糊不单独证明缺陷;之前可读、可到达的内容不能仅因后续滚动而判为不可访问。持续阻碍阅读/操作或违反用户明确视觉标准时才确认相应问题。低对比度观感不能直接推断点击失效或不满足未指定的量化阈值;区分已确认问题、待确认风险和未测项。优先复用已有证据,仅补缺少的观察,不为消除疑点无限重跑。\n- **要求与证据**:沿用现有任务计划,将用户明确要求的前置条件、开关状态、检查动作和交付物列为必要项,区分待检查、已有证据、受阻。仅在状态变化时简短记下要求、来源/步骤和实际观察,不每轮重抄清单。跨端切换前和结束前核对必要项及最新用户指导,只补尚缺且可安全取得的证据;无法补齐时明确列出缺项。用户希望发生、动作已发出或计划已勾选都不证明验收通过。\n- **证据冲突**:关键文字、数值或开关状态有冲突/不清晰时,回查对应步骤原图或控件树,保留来源和不确定性,不按常识或多数摘要猜测。手机与电脑证据分别归属,仅在同条件观察支持时报告跨端差异。保持已有可靠证据,只回查缺失或矛盾部分。\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, soLine truncated
}
+16
View File
@@ -1555,6 +1555,22 @@ class OperatorNode:
"""The recorded target semantics: observed (index) or described (coords)."""
return {k: v for k, v in el.items() if k != "center"}
if tool_name in {"drag", "action_sequence"}:
from artemis.mcp.gestures import DragRequest, SequenceRequest, plan_drag, plan_sequence
try:
if tool_name == "drag":
request = DragRequest.model_validate(args)
planned = plan_drag(request, width, height)
return [{**planned, "args": request.model_dump(mode="json")}], None
request_sequence = SequenceRequest.model_validate(args)
plan_sequence(request_sequence, width, height, self._max_burst_actions())
return [
{"action": "action_sequence", "args": request_sequence.model_dump(mode="json")}
], None
except ValueError as exc:
return [], f"Error during {tool_name}: {exc}"
if tool_name == "click":
target = args.get("target")
times = args.get("times", 1)
+2
View File
@@ -82,6 +82,8 @@ _PRE_DECISION_ALL_TOOLS = (
_PHYSICAL_ACTIONS_ORDER = OPERATOR_SHELL_ORDER
_TURN_ENDING_ORDER = (
"click",
"action_sequence",
"drag",
"swipe",
"input_text",
"long_press",
@@ -103,6 +103,9 @@ async def exec_action(
return False, f"MCP call failed: {e}"
action_item.update(execution="completed" if res.ok else "unknown", side_effect="possible")
action_item.update(res.metadata)
if res.metadata:
action_item["result"] = res.agent_message
expected = (
expected_input_state(wire_args["text"], wire_args["clear_exist"])
if tool_name == "input_text"
+3 -1
View File
@@ -384,7 +384,9 @@ async def run_validation_loop(node, state: State) -> dict:
execution: list[dict] = []
actions_to_execute = list(actions)
total_actions = len(actions_to_execute)
burst = total_actions > 1
burst = total_actions > 1 or any(
a.get("action") == "action_sequence" for a in actions_to_execute
)
if burst:
logger.info(
f"Fast-action burst: {total_actions} actions will fire back to back"
@@ -203,6 +203,8 @@ LONG_PRESS_TOOL = tool_declaration("long_press")
INPUT_TEXT_TOOL = tool_declaration("input_text")
SWIPE_TOOL = tool_declaration("swipe")
DRAG_TOOL = tool_declaration("drag")
ACTION_SEQUENCE_TOOL = tool_declaration("action_sequence")
PRESS_KEY_TOOL = tool_declaration("press_key")
@@ -299,6 +301,8 @@ VALIDATOR_TOOLS_DECLARATION: list[ToolDeclaration] = [
LONG_PRESS_TOOL,
INPUT_TEXT_TOOL,
SWIPE_TOOL,
DRAG_TOOL,
ACTION_SEQUENCE_TOOL,
PRESS_KEY_TOOL,
READ_NOTE_TOOL,
LIST_NOTES_TOOL,
+35 -2
View File
@@ -85,7 +85,17 @@ AGENT_TOOL_NAMES: frozenset[str] = (
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
_SCREEN_CHANGING_ACTIONS: frozenset[str] = frozenset(
{"click", "click_sequence", "long_press", "input_text", "press_key", "swipe", "manage_app"}
{
"click",
"click_sequence",
"action_sequence",
"drag",
"long_press",
"input_text",
"press_key",
"swipe",
"manage_app",
}
)
@@ -337,7 +347,7 @@ class McpActionExecutor:
)
else:
res = await session.call(wire_name, wire_args)
message = finalize(res) if finalize else res.message
message = finalize(res) if finalize else res.agent_message
if res.ok or self._observe_despite_failure(raw_name, res):
obs, observation_settled = await self._observe_after_action(
@@ -393,6 +403,7 @@ class McpActionExecutor:
"post_image_name": post_image_name,
"has_xml": bool(xml_list),
"status": status,
**({"gesture_result": res.metadata} if res.metadata else {}),
**({"source": source} if source and dispatched else {}),
}
if status == "error":
@@ -423,6 +434,7 @@ class McpActionExecutor:
if res.code
in {ActionCode.INVALID_ARGS, ActionCode.UNSUPPORTED, ActionCode.PACKAGE_NOT_FOUND}
else "possible",
**res.metadata,
},
)
@@ -436,6 +448,8 @@ class McpActionExecutor:
return res.code not in (ActionCode.PACKAGE_NOT_FOUND, ActionCode.INVALID_ARGS)
if raw_name == "wait_for_text":
return res.code is ActionCode.TIMEOUT
if raw_name in {"drag", "action_sequence"}:
return res.code not in {ActionCode.INVALID_ARGS, ActionCode.UNSUPPORTED}
return False
# --- Argument translation --------------------------------------------------------
@@ -458,6 +472,25 @@ class McpActionExecutor:
``index_elements`` overrides the list index targets resolve against
(see :meth:`execute`).
"""
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
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)
plan_sequence(
sequence_request,
*self._screen_size(),
limit=load_agent_config().pro.execution.max_burst_actions,
)
return "action_sequence", sequence_request.model_dump(mode="json"), None, {}
except ValueError as exc:
raise _ArgError(f"Error during {raw_name}: {exc}") from exc
if raw_name == "click":
target, recorded = self._resolve_target(
args.get("target") or args.get("coordinates"),
+2
View File
@@ -79,6 +79,8 @@ OPTIONAL_ACTIONS: frozenset[str] = frozenset(
"long_press",
"input_text",
"swipe",
"drag",
"action_sequence",
"press_key",
"manage_app",
"wait_for_delay",
+7 -1
View File
@@ -23,7 +23,8 @@ prompts are the contract. The Operator/Validator-internal vocabulary
``to_canonical_call`` converts a Validator action item (the dicts Operator writes into
``state.structured_decisions``) into a canonical ``(name, kwargs)`` pair for the
actuator/session layer, using the ``normalized_coordinates`` the Operator already
computed so the canonical surface never needs a pixel escape hatch.
computed. Explicit drag/sequence requests preserve their declared coordinate
space so precise pixels are not quantized through normalization.
"""
from typing import Any
@@ -35,6 +36,8 @@ OPERATOR_ACTION_TO_CANONICAL: dict[str, str] = {
"tap": "click",
"long_press_on": "long_press",
"swipe": "swipe",
"drag": "drag",
"action_sequence": "action_sequence",
"focus_and_input_text": "input_text",
"focus_and_clear_text": "focus_and_clear_text",
"erase_one_char": "erase_one_char",
@@ -87,6 +90,9 @@ def to_canonical_call(
if canonical is None:
raise ValueError(f"Unsupported action: {verb}")
if verb in {"drag", "action_sequence"}:
return canonical, dict(action_item.get("args") or {})
if verb == "tap":
pair = _norm_pair(action_item, dims)
if pair is None:
+108 -4
View File
@@ -63,11 +63,13 @@ import types
from typing import Annotated, Any, Literal, Union, get_args, get_origin
from langchain_core.tools import StructuredTool
from langchain_core.utils.function_calling import convert_to_openai_tool
from mcp.types import CallToolResult
from pydantic import Field, create_model
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
__all__ = [
"ACTION_SPECS",
@@ -213,6 +215,14 @@ async def _wire_swipe(actuator: Any, a: dict[str, Any]) -> ActionResult:
)
async def _wire_drag(actuator: Any, a: dict[str, Any]) -> ActionResult:
return await actuator.drag(**a)
async def _wire_action_sequence(actuator: Any, a: dict[str, Any]) -> ActionResult:
return await actuator.action_sequence(a["steps"])
async def _wire_press_key(actuator: Any, a: dict[str, Any]) -> ActionResult:
return await actuator.press_key(a["key"])
@@ -244,6 +254,86 @@ async def _wire_focus_and_clear_text(actuator: Any, a: dict[str, Any]) -> Action
# --- The manifest --------------------------------------------------------------------
_SPECS: tuple[ActionSpec, ...] = (
ActionSpec(
name="action_sequence",
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 "
"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 "
"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."
),
params=(
ParamSpec("steps", list[GestureStep], "Ordered click, wait and drag members."),
),
),
wire=WireDialect(
description="Execute validated short gesture steps consecutively; stop at the first failure.",
params=(ParamSpec("steps", list[GestureStep]),),
bind=_wire_action_sequence,
),
),
ActionSpec(
name="drag",
operator=OperatorDialect(
description=(
"[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. "
"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, "
"and two anchors from the CURRENT zoom/layout. Provide exactly one of end "
"or calibration. Returned geometry is a plan/dispatch receipt, not proof of "
"the selected value: compare the post-action scale/value and correct only "
"the observed residual; zoom further if the pixel resolution is insufficient."
),
params=(
ParamSpec("start", list[int], "Observed handle or pan starting point [x, y]."),
ParamSpec(
"target_description", str, "The slider, handle or timeline being dragged."
),
ParamSpec(
"end",
list[int] | None,
"Exact endpoint. Set null (or omit) when calibration is supplied; never compute both.",
required=False,
),
ParamSpec(
"coordinate_space", CoordinateSpace, required=False, default="normalized"
),
ParamSpec("duration_ms", int, required=False, default=300),
ParamSpec(
"calibration",
DragCalibration | None,
"Observed numeric anchors that compute the endpoint. When supplied, end MUST be null; otherwise set calibration null.",
required=False,
),
),
),
wire=WireDialect(
description="Drag with exact coordinates or an observed two-anchor calibration.",
params=(
ParamSpec("start", list[int]),
ParamSpec("target_description", str),
ParamSpec("end", list[int] | None, required=False),
ParamSpec(
"coordinate_space", CoordinateSpace, required=False, default="normalized"
),
ParamSpec("duration_ms", int, required=False, default=300),
ParamSpec("calibration", DragCalibration | None, required=False),
),
bind=_wire_drag,
),
),
ActionSpec(
name="click",
operator=OperatorDialect(
@@ -470,7 +560,7 @@ _SPECS: tuple[ActionSpec, ...] = (
"[ACTION] Perform a swipe, drag, or slider-adjustment gesture on the screen.\n"
"\n"
"• Directional Scrolling ('direction'): Recommended for general browsing and standard page scrolling in most scenarios. Automatically computes safe swipe vectors and adaptive duration, retains a ~40% visual overlap anchor for zero-omission traversal, and prevents inertial flings. Supports scoping to a sub-container via 'target'. If it fails on certain custom layouts, fall back to specifying exact coordinates ('start' and 'end') directly.\n"
"• Precise Coordinate Gestures ('start', 'end'): Best for local, fine-grained interactions such as adjusting sliders/SeekBars (e.g., volume, brightness, progress bars), drag-and-drop / list reordering, or as a reliable fallback when directional scrolling fails on specific containers. Always drag slightly PAST the target position to overcome touch slop and reliably trigger the update. When setting a slider to Maximum (100%) or Minimum (0%), swipe fully to the extreme boundary.\n"
"• Precise Coordinate Gestures ('start', 'end'): Use observed endpoints for local slider/timeline movement. Do not automatically overshoot: slow short gestures can trigger long-press recognition. Compare the actual post-action value/scale before adjusting again. When available, drag supports pixel coordinates and two-anchor calibration for fine positioning.\n"
"\n"
"Args:\n"
" direction: Smart directional scrolling ('up', 'down', 'left', 'right'). Automatically computes safe swipe vectors, retaining 40% visual overlap: 'up' (reveal content below), 'down' (reveal content above), 'left', 'right'.\n"
@@ -684,6 +774,8 @@ ACTION_SPECS: dict[str, ActionSpec] = {spec.name: spec for spec in _SPECS}
#: identical to the prompt's "Physical device actions" enumeration order).
OPERATOR_SHELL_ORDER: tuple[str, ...] = (
"click",
"action_sequence",
"drag",
"input_text",
"swipe",
"press_key",
@@ -694,6 +786,8 @@ OPERATOR_SHELL_ORDER: tuple[str, ...] = (
#: Exception wording per action; matches the historical executor `except` arms.
EXCEPTION_PREFIXES: dict[str, str] = {
"action_sequence": "Error during action_sequence",
"drag": "Error during drag",
"click": "Error during click",
"click_sequence": "Error executing click sequence",
"long_press": "Error during long press",
@@ -781,8 +875,18 @@ def _json_schema(annotation: Any) -> dict[str, Any]:
raise TypeError(f"No JSON projection for parameter annotation {annotation!r}.") from None
def _projected_parameters(dialect: OperatorDialect) -> dict[str, Any]:
def _projected_parameters(dialect: OperatorDialect, name: str) -> dict[str, Any]:
"""The operator shell's parameters as a JSON-schema ``parameters`` object."""
def has_model(annotation: Any) -> bool:
return (isinstance(annotation, type) and issubclass(annotation, BaseModel)) or any(
has_model(arg) for arg in get_args(annotation)
)
if any(has_model(p.annotation) for p in dialect.params):
# Nullable alternatives matter for endpoint-versus-calibration requests:
# models must be able to send end=null instead of inventing an endpoint.
return convert_to_openai_tool(operator_shell_tool(name))["function"]["parameters"]
properties: dict[str, Any] = {}
required: list[str] = []
for p in dialect.params:
@@ -814,7 +918,7 @@ def tool_declaration(name: str) -> ToolDeclaration:
return ToolDeclaration(
name=name,
description=spec.operator.description,
parameters=_projected_parameters(spec.operator),
parameters=_projected_parameters(spec.operator, name),
)
raise ValueError(f"Action '{name}' has no agent dialect to declare.")
+13
View File
@@ -52,6 +52,19 @@ class ActionResult(BaseModel):
"""Raw driver/controller error for diagnostics; never shown to the LLM."""
normalized_coordinates: list[int] | None = None
duration_ms: int | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
"""Actual geometry, calibration and per-member receipts for compound gestures."""
@property
def agent_message(self) -> str:
"""Expose gesture receipts in the model's tool response as well as the trace."""
if self.action in {"drag", "action_sequence"} and self.metadata:
import json
return (
self.message + "\nGesture receipt: " + json.dumps(self.metadata, ensure_ascii=False)
)
return self.message
@classmethod
def success(cls, action: str, message: str, **kwargs: Any) -> "ActionResult":
+113
View File
@@ -132,6 +132,119 @@ class AdbActuator:
# --- Required action -------------------------------------------------------------
async def drag(self, **kwargs: Any) -> ActionResult:
"""Dispatch exact pixel geometry without a normalized-coordinate round trip."""
from artemis.mcp.gestures import DragRequest, plan_drag
try:
planned = plan_drag(DragRequest.model_validate(kwargs), *self._dims())
except ValueError as exc:
return ActionResult.failure("drag", str(exc), code=ActionCode.INVALID_ARGS)
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 error:
return ActionResult.failure("drag", str(error), metadata=metadata)
return ActionResult.success(
"drag",
f"Dragged from {planned['coordinates'][:2]} to {planned['coordinates'][2:]}"
f" (pixel) over {planned['duration_ms']}ms. Verify the scale/value in the next observation.",
duration_ms=planned["duration_ms"],
metadata=metadata,
)
async def action_sequence(self, steps: list[Any]) -> ActionResult:
"""Validate the entire sequence, then execute once with no intermediate observation."""
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.runtime.task_control import TaskControl, has_pending_instruction
try:
planned = plan_sequence(
SequenceRequest.model_validate({"steps": steps}),
*self._dims(),
limit=load_agent_config().pro.execution.max_burst_actions,
)
except ValueError as exc:
return ActionResult.failure("action_sequence", str(exc), code=ActionCode.INVALID_ARGS)
started = time.monotonic()
receipts = [
{**step, "index": i + 1, "execution": "not_started"} for i, step in enumerate(planned)
]
failed = None
for step, receipt in zip(planned, receipts, strict=True):
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl) and (
has_pending_instruction(control.directory)
or (control.directory / "pause.request").exists()
):
failed = "Sequence interrupted before the next input; re-observe before continuing."
break
with TraceSpan(
name=f"action_sequence.{step['action']}", trace_type="tool", ctx=self.ctx
) as span:
span.payload = {"args": step}
receipt["started_ms"] = round((time.monotonic() - started) * 1000, 3)
receipt["execution"] = "unknown"
try:
if step["action"] == "click":
result = await self.controller.tap_at(*step["coordinates"])
error = getattr(result, "error", None)
elif step["action"] == "drag":
result = await self._dispatch_drag(step)
error = None if result.ok else result.message
else:
await asyncio.sleep(step["time_in_ms"] / 1000)
error = None
receipt.update(execution="unknown" if error else "completed", error=error)
if error:
failed = str(error)
span.status = "failed"
span.error = failed
except asyncio.CancelledError:
receipt.update(execution="unknown", error="Input cancelled; effect uncertain.")
raise
except (OSError, RuntimeError) as exc:
receipt.update(execution="unknown", error=str(exc))
failed = str(exc)
span.status = "failed"
span.error = failed
finally:
receipt["ended_ms"] = round((time.monotonic() - started) * 1000, 3)
span.result = dict(receipt)
span.payload["receipt"] = dict(receipt)
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)
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,
"execution": execution,
"side_effect": "possible" if attempted else "none",
}
if failed:
return ActionResult.failure(
"action_sequence",
f"Sequence stopped after {completed}/{len(planned)} completed steps: {failed}"
" Remaining steps were not executed; do not replay completed inputs.",
metadata=metadata,
)
return ActionResult.success(
"action_sequence",
f"Executed {completed} gesture steps consecutively. Verify the final screen.",
metadata=metadata,
)
async def click_sequence(
self, points: list[tuple[int, int]], delay_ms: int = 50
) -> ActionResult:
+6 -1
View File
@@ -40,7 +40,8 @@ __all__ = ["Actuator"]
class Actuator(Protocol):
"""Protocol every actuator backend implements.
All coordinates are 0-1000 normalized. Every action coroutine returns an
Coordinates are 0-1000 normalized, except drag/sequence members explicitly
marked coordinate_space="pixel". Every action coroutine returns an
:class:`ActionResult` and must not raise for device-level failures -- those are
reported through ``ok=False`` so a failed tap reaches the agent as an observation
rather than an exception.
@@ -84,6 +85,10 @@ class Actuator(Protocol):
async def press_key(self, key: str) -> ActionResult: ...
async def drag(self, **kwargs: Any) -> ActionResult: ...
async def action_sequence(self, steps: list[Any]) -> ActionResult: ...
async def manage_app(self, action: str, app_name: str) -> ActionResult: ...
async def wait_for_delay(self, time_in_ms: int) -> ActionResult: ...
+187
View File
@@ -0,0 +1,187 @@
"""Validated, shared plans for short gesture sequences and calibrated drags.
Planning is pure: every member is checked before the first device input. Scale
values are supplied from observations by the caller, never inferred by the driver.
"""
import math
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator
class GestureModel(BaseModel):
model_config = ConfigDict(
extra="forbid", strict=True, allow_inf_nan=False, str_strip_whitespace=True
)
Point = Annotated[list[StrictInt], Field(min_length=2, max_length=2)]
Duration = Annotated[int, Field(strict=True, ge=1, le=10000)]
CoordinateSpace = Literal["normalized", "pixel"]
class ScaleAnchor(GestureModel):
position: float = Field(
description="Observed axis coordinate in the selected coordinate space."
)
value: float = Field(description="Observed scale value, in one consistent numeric unit.")
class DragCalibration(GestureModel):
axis: Literal["x", "y"]
mode: Literal["thumb", "pan"] = Field(
description="thumb moves a handle to a value; pan moves a scale under a fixed indicator."
)
anchors: list[ScaleAnchor] = Field(min_length=2, max_length=2)
target_value: float
indicator_position: float | None = Field(
default=None, description="Required for pan: the fixed indicator's observed axis position."
)
class DragRequest(GestureModel):
start: Point
target_description: str = Field(min_length=1)
end: Point | None = Field(
default=None, description="Exact endpoint; MUST be null when calibration is supplied."
)
coordinate_space: CoordinateSpace = "normalized"
duration_ms: Duration = 300
calibration: DragCalibration | None = Field(
default=None, description="Computes the endpoint from observed anchors; then set end=null."
)
@model_validator(mode="after")
def endpoint_or_calibration(self):
if (self.end is None) == (self.calibration is None):
raise ValueError(
"Supply exactly one of end or calibration. Calibration computes the endpoint: set end=null (or omit end) when calibration is supplied. Do not repeat the rejected combination."
)
return self
class ClickStep(GestureModel):
action: Literal["click"]
target: Point
target_description: str = Field(min_length=1)
coordinate_space: CoordinateSpace = "normalized"
class DragStep(DragRequest):
action: Literal["drag"]
class WaitStep(GestureModel):
action: Literal["wait"]
time_in_ms: int = Field(strict=True, ge=0, le=10000)
GestureStep = ClickStep | DragStep | WaitStep
class SequenceRequest(GestureModel):
steps: list[GestureStep] = Field(min_length=1, max_length=10)
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.")
limits = [1001, 1001] if space == "normalized" else [width, height]
if any(p < 0 or p >= limit for p, limit in zip(point, limits, strict=True)):
raise ValueError(f"Point {point} is outside the {space} screen bounds.")
if space == "pixel":
return list(point)
return [min(size - 1, p * size // 1000) for p, size in zip(point, [width, height], strict=True)]
def plan_drag(request: DragRequest, width: int, height: int) -> dict:
start = pixel_point(request.start, request.coordinate_space, width, height)
calibration = None
if request.calibration is None:
if request.end is None:
raise ValueError("Drag requires end or calibration.")
end = pixel_point(request.end, request.coordinate_space, width, height)
else:
scale = request.calibration
axis = 0 if scale.axis == "x" else 1
size = width if axis == 0 else height
def position(raw: float) -> float:
upper = 1000 if request.coordinate_space == "normalized" else size - 1
if not 0 <= raw <= upper:
raise ValueError("Calibration positions must be inside the current screen.")
return (
min(size - 1, raw * size / 1000)
if request.coordinate_space == "normalized"
else raw
)
a, b = scale.anchors
p1, p2 = position(a.position), position(b.position)
if p1 == p2 or a.value == b.value:
raise ValueError("Calibration needs two distinct positions and values.")
slope = (b.value - a.value) / (p2 - p1)
if not math.isfinite(slope) or slope == 0:
raise ValueError("Calibration slope must be finite and nonzero.")
target_position = p1 + (scale.target_value - a.value) / slope
if not math.isfinite(target_position):
raise ValueError("Calibrated target must be finite.")
if scale.mode == "thumb":
if not min(a.value, b.value) <= scale.target_value <= max(a.value, b.value):
raise ValueError("Thumb target must be within the observed calibration range.")
endpoint = target_position
else:
if scale.indicator_position is None:
raise ValueError("Pan calibration requires indicator_position.")
endpoint = start[axis] + position(scale.indicator_position) - target_position
if not 0 <= endpoint <= size - 1:
raise ValueError(
"Calibrated endpoint is outside the screen; zoom or use a smaller move."
)
end = list(start)
end[axis] = round(endpoint)
calibration = {
**scale.model_dump(mode="json"),
"anchor_coordinate_space": request.coordinate_space,
"value_per_pixel": slope,
"target_position_pixel": target_position,
"rounding_error_value": abs(end[axis] - endpoint) * abs(slope),
"verification": "pending_observation",
}
if start == end:
raise ValueError("Drag has no pixel movement; zoom further or choose a different endpoint.")
return {
"action": "drag",
"coordinates": [*start, *end],
"coordinate_space": "pixel",
"screen_size": [width, height],
"normalized_coordinates": [
round(value * 1000 / size)
for value, size in zip([*start, *end], [width, height, width, height], strict=True)
],
"duration_ms": request.duration_ms,
"target_description": request.target_description,
**({"calibration": calibration} if calibration else {}),
}
def plan_sequence(request: SequenceRequest, width: int, height: int, limit: int) -> list[dict]:
if len(request.steps) > limit:
raise ValueError(f"Sequence exceeds the configured fast-action limit of {limit} steps.")
planned = []
for step in request.steps:
if isinstance(step, DragStep):
planned.append(plan_drag(step, width, height))
elif isinstance(step, ClickStep):
planned.append(
{
"action": "click",
"coordinates": pixel_point(step.target, step.coordinate_space, width, height),
"coordinate_space": "pixel",
"target_description": step.target_description,
}
)
else:
planned.append(step.model_dump(mode="json"))
return planned
+22 -10
View File
@@ -417,8 +417,10 @@ def normalize_step_actions(step_dict: dict[str, Any]) -> dict[str, Any]:
act_name = str(item.get("action") or item.get("name") or "").lower()
if (
"swipe" in act_name or "scroll" in act_name or "drag" in act_name
) and "normalized_coordinates" not in item:
("swipe" in act_name or "scroll" in act_name or "drag" in act_name)
and "normalized_coordinates" not in item
and item.get("coordinate_space") != "pixel"
):
kind, target, _ = parse_swipe_parameters(item)
if kind == "coords" and isinstance(target, list) and len(target) == 4:
item["normalized_coordinates"] = target
@@ -445,22 +447,32 @@ def normalize_step_actions(step_dict: dict[str, Any]) -> dict[str, Any]:
coords = item.get("coordinates")
if coords and isinstance(coords, list) and "normalized_coordinates" not in item:
# If coordinates look physical (e.g., target_bounds present or > 1000), normalize using width/height
if item.get("target_bounds") or any(
isinstance(c, (int, float)) and c > 1000 for c in coords
if (
item.get("coordinate_space") == "pixel"
or item.get("target_bounds")
or any(isinstance(c, (int, float)) and c > 1000 for c in coords)
):
size = item.get("screen_size")
if not (
isinstance(size, (list, tuple))
and len(size) == 2
and all(isinstance(v, (int, float)) and v > 0 for v in size)
):
size = [width, height]
item_width, item_height = size
if len(coords) == 2 and all(isinstance(c, (int, float)) for c in coords):
item["normalized_coordinates"] = [
int(round(coords[0] * 1000.0 / width)),
int(round(coords[1] * 1000.0 / height)),
int(round(coords[0] * 1000.0 / item_width)),
int(round(coords[1] * 1000.0 / item_height)),
]
elif len(coords) == 4 and all(isinstance(c, (int, float)) for c in coords):
nx1, ny1 = (
int(round(coords[0] * 1000.0 / width)),
int(round(coords[1] * 1000.0 / height)),
int(round(coords[0] * 1000.0 / item_width)),
int(round(coords[1] * 1000.0 / item_height)),
)
nx2, ny2 = (
int(round(coords[2] * 1000.0 / width)),
int(round(coords[3] * 1000.0 / height)),
int(round(coords[2] * 1000.0 / item_width)),
int(round(coords[3] * 1000.0 / item_height)),
)
item["normalized_coordinates"] = [nx1, ny1, nx2, ny2]
item["normalized_start_coordinates"] = [nx1, ny1]
+28 -6
View File
@@ -113,7 +113,16 @@ def format_action_clean(action_obj) -> str:
clear_exist = action_obj.get("clear_exist") or action_obj.get("clear_before_input")
clear_str = " (without clearing)" if clear_exist is False else ""
return f"Inputted '{text_val}' into {label} at {coords}{clear_str}"
elif act_type == "swipe":
elif act_type == "action_sequence":
members = action_obj.get("sequence_results") or action_obj.get("steps") or []
if not members and isinstance(action_obj.get("args"), dict):
members = action_obj["args"].get("steps") or []
return "Gesture sequence: " + "; ".join(
f"{m.get('action')} {m.get('target_description', '')}"
f" [{m.get('execution', 'requested')}]"
for m in members
)
elif act_type in {"swipe", "drag"}:
direction = action_obj.get("action_direction") or action_obj.get("direction")
if not direction and isinstance(action_obj.get("gesture"), str):
# Flash swipe-by-direction records the direction as ``gesture``
@@ -141,7 +150,8 @@ def format_action_clean(action_obj) -> str:
return f"Swiped {direction}{path}{duration_str}"
dragged = _target_label(action_obj, fallback="")
dragged_str = f" {dragged}" if dragged else ""
return f"Swiped{dragged_str} from {start_coords} to {end_coords}{duration_str}"
verb = "Dragged" if act_type == "drag" else "Swiped"
return f"{verb}{dragged_str} from {start_coords} to {end_coords}{duration_str}"
elif act_type == "press_key":
return f"Pressed key '{keycode}'"
elif act_type == "launch_app":
@@ -325,17 +335,27 @@ def format_result_clean(result_obj) -> str | None:
if not isinstance(result_obj, dict):
return str(result_obj)
execution = result_obj.get("execution")
gestures = [
a["result"]
for a in (execution if isinstance(execution, list) else [])
if isinstance(a, dict)
and a.get("action") in {"drag", "action_sequence"}
and isinstance(a.get("result"), str)
]
receipt = "\n".join(gestures)
suffix = "\n" + receipt if receipt else ""
incident = result_obj.get("incident")
if isinstance(incident, dict) and incident.get("reason"):
return format_incident_clean(incident)
return format_incident_clean(incident) + suffix
status = result_obj.get("status")
error = failed_execution_error(result_obj)
if error:
return _error_line(error)
return _error_line(error) + suffix
if status in ("failed", "error"):
return "Error"
return None
return "Error" + suffix
return receipt or None
def _error_line(error: str) -> str:
@@ -414,6 +434,8 @@ def format_tool_call_clean(
"click_sequence",
"tap_sequence",
"swipe",
"drag",
"action_sequence",
"input_text",
"focus_and_input_text",
"press_key",
+42 -7
View File
@@ -517,14 +517,20 @@ def crop_and_annotate_target(
return out_buf.getvalue()
def _resolve_coordinates(coord: Any, width: int, height: int) -> tuple[int, int] | None:
def _resolve_coordinates(
coord: Any, width: int, height: int, coordinate_space: str | None = None
) -> tuple[int, int] | None:
"""Helper to safely parse coordinates into pixel space."""
if not isinstance(coord, (list, tuple)) or len(coord) < 2:
return None
try:
x, y = float(coord[0]), float(coord[1])
# If coordinates are normalized (0.0 <= x <= 1.0)
if 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0:
if coordinate_space == "pixel":
px, py = int(round(x)), int(round(y))
elif coordinate_space == "normalized":
px, py = int(round(x * width / 1000)), int(round(y * height / 1000))
elif 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0:
px = int(round(x * width))
py = int(round(y * height))
elif 0 <= x <= 1000 and 0 <= y <= 1000 and (width > 1000 or height > 1000):
@@ -563,6 +569,17 @@ def draw_action_overlay_on_image(
if not image_bytes:
return image_bytes
if action_name == "action_sequence":
receipts = action_args.get("sequence_results")
annotated = image_bytes
for member in receipts if isinstance(receipts, list) else []:
if (
isinstance(member, dict)
and member.get("execution") == "completed"
and member.get("action") in {"click", "drag"}
):
annotated = draw_action_overlay_on_image(annotated, member["action"], member)
return annotated
try:
img = Image.open(io.BytesIO(image_bytes))
if img.mode in ("RGBA", "P"):
@@ -584,7 +601,7 @@ def draw_action_overlay_on_image(
or action_args.get("coordinates")
or action_args.get("point")
)
pt = _resolve_coordinates(coord, width, height)
pt = _resolve_coordinates(coord, width, height, action_args.get("coordinate_space"))
if pt:
x, y = pt
# Outer white aura
@@ -637,7 +654,9 @@ def draw_action_overlay_on_image(
if isinstance(sequence, list):
pts = []
for coord in sequence:
pt = _resolve_coordinates(coord, width, height)
pt = _resolve_coordinates(
coord, width, height, action_args.get("coordinate_space")
)
if pt:
pts.append(pt)
@@ -703,8 +722,12 @@ def draw_action_overlay_on_image(
start_pt, end_pt = None, None
if kind == "coords" and isinstance(target, list) and len(target) == 4:
start_pt = _resolve_coordinates(target[:2], width, height)
end_pt = _resolve_coordinates(target[2:], width, height)
start_pt = _resolve_coordinates(
target[:2], width, height, action_args.get("coordinate_space")
)
end_pt = _resolve_coordinates(
target[2:], width, height, action_args.get("coordinate_space")
)
elif kind == "direction" and isinstance(target, str):
direction = target.lower()
cx, cy = width // 2, height // 2
@@ -758,7 +781,7 @@ def draw_action_overlay_on_image(
elif action in ("input_text", "type"):
coord = action_args.get("target") or action_args.get("coordinates")
if coord:
pt = _resolve_coordinates(coord, width, height)
pt = _resolve_coordinates(coord, width, height, action_args.get("coordinate_space"))
if pt:
x, y = pt
box_w = max(60, int(base_dim * 0.15))
@@ -805,9 +828,21 @@ def overlay_action_on_screenshot(image_bytes: bytes, action_obj: Any) -> bytes |
"sequence",
"targets",
"normalized_coordinates",
"coordinate_space",
"sequence_results",
):
if key in action and key not in action_args:
action_args[key] = action[key]
if (
act_name == "drag"
and isinstance(action.get("coordinates"), list)
and len(action["coordinates"]) == 4
):
action_args.update(
start=action["coordinates"][:2],
end=action["coordinates"][2:],
coordinate_space=action.get("coordinate_space"),
)
annotated = draw_action_overlay_on_image(
image_bytes=image_bytes, action_name=act_name, action_args=action_args
)