feat(android): add native controls and continuous multi-touch

This commit is contained in:
chenmingxuan committed 2026-11-02 21:33:45 +08:00
1 parent 3dd96078db
commit 4b12ae7728
70 files changed
+13105 -179

No files matched your search

@@ -0,0 +1,103 @@
"""Guard the prompt evidence contract; no model or device is invoked here."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.checker.checker import (
CheckReport,
assemble_checker_prompt_segments,
run_final_check,
)
from artemis.context import ArtemisContext, ExecutionSetup
@pytest.mark.parametrize("entry", ["checkpoint", "final"])
def test_bound_persistent_readout_can_supply_precision_beyond_pixel_ticks(entry):
prompt = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False)
# Native origin alone is insufficient: the rule must bind the displayed value
# to the selected state and require stability plus the visible pointer check.
for requirement in (
"标签、节点角色/层级",
"目标控件当前所选值或固定指针所选时间",
"不能仅因原生节点含有数值或执行者称其可信",
"在被验收状态中持续显示",
"引用已停稳或暂停的证据",
"与同一控件的指针及可辨刻度一致",
"结合读数精度与用户容差",
"粗刻度无法独立区分秒数,不等于",
"无需再由粗像素刻度独立插值证明同一精确值",
):
assert requirement in prompt
assert "拖动浮层或原生时间读数与固定指针刻度冲突时" not in prompt
@pytest.mark.parametrize("entry", ["checkpoint", "final"])
def test_native_origin_does_not_promote_unbound_or_conflicting_evidence(entry):
prompt = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False)
for excluded_source in ("事件卡片时间", "视频画面水印", "录制起止时间", "拖动/松手浮层"):
assert excluded_source in prompt
assert "即使它们也能通过原生节点读取,仍须先证明上述绑定与持久性" in prompt
assert "可辨精度上存在实际冲突" in prompt
assert "读数仍在变化、绑定不明或无法确认已停稳/暂停" in prompt
assert "应判证据不足(inconclusive)" in prompt
assert "不得挑选有利读数或忽略矛盾" in prompt
assert "可信读数明确超出用户容差时仍按通用规则判定" in prompt
@pytest.mark.parametrize("entry", ["checkpoint", "final"])
def test_pixel_fallback_remains_quantitative_and_requires_post_action_evidence(entry):
prompt = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False)
fallback = prompt.split("没有可信持久读数时,", 1)[1]
assert "动作后已停稳截图的两处真实数值刻度与固定指针位置作线性插值" in fallback
assert "单位换算、估读不确定性及目标误差" in fallback
assert "目标误差加估读不确定性不超过用户容差" in fallback
assert "刻度非线性或无法确定已停稳,仍应判证据不足" in fallback
assert "操作前的校准计划及回执误差不能替代动作后测量" in fallback
@pytest.mark.asyncio
async def test_final_review_receives_live_readout_and_rules_separately_from_report_claims():
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup()
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = "unused"
ctx.data_engine.get_agent_friendly_steps.return_value = []
ctx.data_engine.get_step_number.return_value = 7
elements = "[1] 当前所选值: 23.5; [2] 已暂停"
review = AsyncMock(return_value=CheckReport(verdicts=[]))
with (
patch("artemis.agents.checker.checker._run_check_loop", new=review),
patch(
"artemis.agents.checker.checker._capture_final_screen",
new=AsyncMock(return_value=("captured-image", elements)),
),
):
await run_final_check(
ctx,
goal="核对当前所选值及容差",
plan_text="",
ledger=[],
check_items=[],
execution_report="执行者声称当前所选值为 88",
)
review.assert_awaited_once()
messages = review.await_args.args[1]
assert "无需再由粗像素刻度独立插值证明同一精确值" in messages[0].content
assert "不得挑选有利读数或忽略矛盾" in messages[0].content
content = messages[1].content
text = content[0]["text"]
assert "Executor's Final Report (claims to verify, not independent evidence)" in text
assert "执行者声称当前所选值为 88" in text
assert f"# Final Screen Elements\n{elements}" in text
assert any(
block.get("type") == "image_url"
and block["image_url"]["url"] == "data:image/jpeg;base64,captured-image"
for block in content
)
+194
View File
@@ -0,0 +1,194 @@
"""Actual action-result/history/transcript rendering must keep native evidence."""
from copy import deepcopy
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
import pytest
from artemis.agents.validator.action_execution import exec_action
from artemis.mcp.action_types import ActionCode, ActionResult
from artemis.memory.transcript import EXECUTION_RESULT_MARKER, TranscriptLedger
from artemis.utils.task_tree import format_result_clean
NATIVE_ACTIONS = (
"pinch",
"touch_gesture",
"read_control",
"control_action",
"wait_until",
"scroll_to",
)
TOKEN = "a" * 64
SELECTOR = {"resource_id": "fixture:id/editor"}
def action_arguments(action):
drag = {
"start": [191, 501],
"end": [192, 532],
"coordinate_space": "pixel",
"target_description": "observed canvas",
}
return {
"pinch": {
"center": [200, 400],
"start_span": 100,
"end_span": 200,
"coordinate_space": "pixel",
"target_description": "observed canvas",
},
"touch_gesture": {
"frames": [
{"at_ms": 0, "points": [[191, 501]]},
{"at_ms": 400, "points": [[192, 532]]},
],
"coordinate_space": "pixel",
"target_description": "observed canvas",
},
"read_control": {"selector": SELECTOR},
"control_action": {
"selector": SELECTOR,
"action": "set_selection",
"selection_start": 3,
"selection_end": 5,
},
"wait_until": {"selector": SELECTOR, "state": "value", "expected_value": 37.5},
"scroll_to": {"container": {"resource_id": "fixture:id/list"}, "target": SELECTOR},
"drag": drag,
"action_sequence": {"steps": [{"action": "drag", **drag}]},
}[action]
def control_observation():
return {
"resource_id": "fixture:id/editor",
"text": " A😀B \n",
"selection_start": 3,
"selection_end": 5,
"range": {"min": 0, "max": 100, "current": 37.5, "type": 1},
"token": TOKEN,
"content_signature": "b" * 64,
"editable": True,
"password": False,
}
def decode_receipt(text, label):
marker = f"{label} receipt: "
assert marker in text
return json.loads(text.split(marker, 1)[1])
async def render_result_pipeline(action, metadata, failed):
message = (
"Native reply is uncertain; observe before continuing." if failed else "Input accepted."
)
result = (
ActionResult.failure(action, message, code=ActionCode.TIMEOUT, metadata=metadata)
if failed
else ActionResult.success(action, message, metadata=metadata)
)
session = SimpleNamespace(call=AsyncMock(return_value=result))
context = SimpleNamespace(
data_engine=None, device=SimpleNamespace(device_width=400, device_height=800)
)
args = action_arguments(action)
item = {"action": action, "args": args}
ok, error = await exec_action(context, session, item)
assert ok is not failed
session.call.assert_awaited_once_with(action, args)
assert item["result"] == result.agent_message
report = {
"status": "failed" if failed else "dispatched",
"execution": [{**item, "attempts": [f"Error: {error}" if failed else "Dispatched"]}],
}
history = format_result_clean(report)
assert isinstance(history, str)
ledger = TranscriptLedger(clock=lambda: 0.0)
ledger.stage_turn(
[
HumanMessage(content="Observed native controls"),
AIMessage(content="", tool_calls=[{"name": action, "args": {}, "id": "native-call"}]),
ToolMessage(content="Action Recorded", tool_call_id="native-call"),
]
)
ledger.commit_staged(step_key="native-step", validator_result=report)
rendered = ledger.render([HumanMessage(content="Next observation")])
execution_results = [
message.content[0]["text"]
for message in rendered
if isinstance(message, HumanMessage)
and isinstance(message.content, list)
and message.content
and EXECUTION_RESULT_MARKER in message.content[0].get("text", "")
]
assert len(execution_results) == 1
transcript = execution_results[0]
assert f"Status: {report['status']}" in transcript
if failed:
assert "Error:" in history and "Error:" in transcript
return result.agent_message, history, transcript
@pytest.mark.asyncio
@pytest.mark.parametrize("action", NATIVE_ACTIONS)
@pytest.mark.parametrize("failed", [False, True])
async def test_every_native_action_keeps_readings_selection_range_and_token_in_actual_result_rendering(
action, failed
):
node = control_observation()
read_only = action in {"read_control", "wait_until"}
execution = (
("not_started" if read_only else "unknown")
if failed
else ("observed" if read_only else "completed")
)
# The renderer must retain optional observed evidence even on gesture receipts.
metadata = {
"action": action,
"execution": execution,
"node": deepcopy(node),
"native_receipt": {
"success": not failed,
"execution": execution,
"code": "TIMEOUT" if failed else "OK",
"node": deepcopy(node),
},
"verification": "pending_observation",
}
stages = await render_result_pipeline(action, metadata, failed)
for rendered in stages:
recovered = decode_receipt(rendered, "Native")
assert recovered == metadata
assert recovered["node"]["text"] == " A😀B \n"
assert recovered["node"]["range"]["current"] == 37.5
assert (recovered["node"]["selection_start"], recovered["node"]["selection_end"]) == (3, 5)
assert recovered["node"]["token"] == TOKEN
assert recovered["native_receipt"]["success"] is not failed
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["drag", "action_sequence"])
@pytest.mark.parametrize("failed", [False, True])
async def test_existing_drag_and_sequence_geometry_and_partial_receipts_still_render(
action, failed
):
metadata = {
"execution": "partial" if failed else "completed",
"coordinate_space": "pixel",
"start": [191, 501],
"end": [192, 532],
"calibration": {"axis": "y", "target_value": 37.5},
}
if action == "action_sequence":
metadata["sequence_results"] = [
{"action": "click", "execution": "completed", "completed_taps": 1},
{"action": "drag", "execution": "unknown" if failed else "completed"},
]
for rendered in await render_result_pipeline(action, metadata, failed):
assert decode_receipt(rendered, "Gesture") == metadata
+39 -2
View File
@@ -35,6 +35,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, Tool
from artemis.agents.operator.operator import OperatorNode
from artemis.agents.operator.prompts import load_operator_prompts
from artemis.agents.operator.prompts import (
OPERATOR_PROMPT_TOOLSET,
PLAN_HISTORY_STATIC_POINTER,
PLAN_HISTORY_TEMPLATE_SECTION,
PromptBuilder,
@@ -54,8 +55,8 @@ from artemis.memory.transcript import (
# SHA-256 snapshots of the legacy system message with the fixed inputs below.
# Update these when an intentional template change alters the rendered prompt.
GOLDEN_EMPTY_PLAN = "23c3d6b5fc3694275e7015f7bd0901bde54aae54fec7a8c68d4f8529c65ef4bc"
GOLDEN_SENTINEL_PLAN = "370c53f1d088a4120c63fbaea6d895f121c683b8d3c8cc7d61105d6e1d223128"
GOLDEN_EMPTY_PLAN = "f5ee50f4923fed4cb943cb80087d39ffffe66f259cfae4a54f27a244d7b06453"
GOLDEN_SENTINEL_PLAN = "bb725e2d096f6aa0cf1773809b2d0e672475589782b9c72348d9eaf8481230b9"
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
@@ -84,6 +85,42 @@ async def test_legacy_prompt_bytes_match_chinese_prompt_golden():
assert hashlib.sha256(sentinel.encode("utf-8")).hexdigest() == GOLDEN_SENTINEL_PLAN
@pytest.mark.asyncio
@pytest.mark.parametrize("transcript", [False, True])
async def test_legacy_and_transcript_prompts_omit_all_unavailable_native_capabilities(transcript):
native_tools = {
"pinch",
"touch_gesture",
"read_control",
"control_action",
"wait_until",
"scroll_to",
}
available_tools = OPERATOR_PROMPT_TOOLSET - native_tools
ctx = SimpleNamespace(
execution_setup=None,
actuator=SimpleNamespace(capabilities=lambda: available_tools),
data_engine=None,
)
state = SimpleNamespace(initial_goal="FILTERED_FIXED_GOAL")
if transcript:
system = render_transcript_static_system(load_operator_prompts(), ctx, state)
else:
builder = PromptBuilder()
await TemplatePromptComponent()(
builder,
state,
ctx,
prompts=load_operator_prompts(),
template_name="main_template",
)
system = "".join(builder.system_parts)
assert "FILTERED_FIXED_GOAL" in system
assert "`click`" in system and "`drag`" in system
for tool in native_tools:
assert tool not in system
def test_main_template_contains_exactly_one_plan_history_section():
template = load_operator_prompts()["main_template"]
assert template.count(PLAN_HISTORY_TEMPLATE_SECTION) == 1
+39 -9
View File
@@ -38,6 +38,14 @@ OPERATOR_DEVICE_TOOLS = (
"wait_for_delay",
"long_press",
)
OPERATOR_NATIVE_TOOLS = (
"pinch",
"touch_gesture",
"read_control",
"control_action",
"wait_until",
"scroll_to",
)
def _tool_refs(text: str, tool: str) -> list[str]:
@@ -88,6 +96,7 @@ def test_full_toolset_is_the_default():
@pytest.mark.parametrize(
"tool",
OPERATOR_DEVICE_TOOLS
+ OPERATOR_NATIVE_TOOLS
+ ("video_analyzer", "search_history", "replay_steps", "get_step_screenshot"),
)
def test_removed_tool_leaves_no_reference(tool):
@@ -99,6 +108,23 @@ def test_removed_tool_leaves_no_reference(tool):
assert not _tool_refs(out, tool), f"'{tool}' still referenced in {name}"
def test_removing_all_native_capabilities_leaves_no_native_names_in_any_template():
available_tools = OPERATOR_PROMPT_TOOLSET - set(OPERATOR_NATIVE_TOOLS)
for name, template in load_operator_prompts().items():
out = apply_operator_prompt_contract(template, available_tools=available_tools)
for tool in OPERATOR_NATIVE_TOOLS:
assert tool not in out, f"'{tool}' still advertised without native capability in {name}"
assert "`click`" in out and "`drag`" in out
assert (
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`,"
" `press_key`, `manage_app`, `wait_for_delay`, `long_press`)" in out
)
assert (
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`,"
" `long_press`, `press_key`, `manage_app`, or `wait_for_delay`)" in out
)
def test_removed_swipe_takes_its_teaching_along():
"""Gating removes the drag/slider teaching, not just the backticked name."""
template = load_operator_prompts()["main_template"]
@@ -131,26 +157,30 @@ def test_reduced_enums_stay_well_formed():
available_tools=OPERATOR_PROMPT_TOOLSET - {"manage_app", "wait_for_delay"},
)
assert (
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`, `press_key`, `long_press`)"
in out
"Physical device actions (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `input_text`, `swipe`,"
" `press_key`, `long_press`)" in out
)
assert (
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`, `long_press`, or `press_key`)"
in out
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `swipe`, `input_text`,"
" `long_press`, or `press_key`)" in out
)
def test_full_set_enum_slots_render_verbatim():
"""With every tool present the historical enumeration wording is reproduced."""
"""Every supported tool appears in the exact physical and turn-ending order."""
template = load_operator_prompts()["main_template"]
out = apply_operator_prompt_contract(template)
assert (
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`, `press_key`,"
" `manage_app`, `wait_for_delay`, `long_press`)" in out
"Physical device actions (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `input_text`, `swipe`,"
" `press_key`, `manage_app`, `wait_for_delay`, `long_press`)" in out
)
assert (
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`, `long_press`,"
" `press_key`, `manage_app`, or `wait_for_delay`)" in out
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `swipe`, `input_text`,"
" `long_press`, `press_key`, `manage_app`, or `wait_for_delay`)" in out
)
assert "Helper/Subagent tools (`ask_explorer`, `ask_diagnoser`, `video_analyzer`)" in out
assert "ADB/task tools (`run_adb_command`, `manage_task`, `android_file`)" in out