feat: extract in-process MCP action layer, concurrent task dispatch, and spawn/reconcile hardening
- Extract device action execution into artemis/mcp action layer: typed ActionResult, capability manifest with startup validation, AdbActuator as the single physical implementation, in-memory ActionSession (replaces stdio subprocess MCP in validator), and McpActionExecutor preserving the legacy executor contract. adb_server keeps its external stdio/SSE surface unchanged and now reuses the actuator implementation; import-time side effects moved into configure_stdio_mode(). - Capability-gated prompt assembly (prompt_assembly.py): tool enums and teaching segments render per available backend tools; full-set output is byte-identical to the previous static text (locked by tests). - Admin console task queue: serial worker replaced by a per-device concurrent dispatcher (ARTEMIS_CONCURRENCY_MODE=global restores serial semantics), per-session cancellation, strict device binding on enqueue, unified stdio piping with tee to trace stdout.log, and shutdown cleanup of all active runs. Fix _pid_exists exception order so PermissionError is treated as alive. - mcp_server reliability: spawn watchdog kills runners stuck before interpreter startup and releases device reservations; task state reconcile uses psutil liveness, prefers runner-reported pids, and adds a 45s startup grace to stop false failure reports on Windows; conversation_id is now optional end-to-end; explicit device_serial is validated at submission. - CLI install now writes ~/.claude/rules/artemis.md for Claude Code and migrates/removes the legacy CLAUDE.md injection block; ~/.claude.json is never rewritten when it fails to parse. - Tests: Windows-portable stdio lifecycle and path assertions; new suites for action layer, prompt assembly, spawn watchdog, and task reconcile (895 passed, 3 skipped).
This commit is contained in:
1 parent
60312abbb0
commit
cbdca1497c
52 files changed
+5735
-1649
No files matched your search
@@ -0,0 +1,546 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""MCP-backed action executor for FlashRunner and FailureAnalyzer.
|
||||
|
||||
Same public contract as the legacy ``MobileActionExecutor`` --
|
||||
``execute(name, args, tool_call_id, state) -> ToolExecutionResult`` -- but execution
|
||||
routes through the in-process action MCP session, and dispatch is table-driven so a
|
||||
backend's extension tools are callable without touching this file.
|
||||
|
||||
Kept agent-side (by design, not omission): argument normalization, element-index
|
||||
resolution against LangGraph ``State``, smart-swipe resolution, act-then-observe
|
||||
capture and ``State`` write-back, tracing (a ``ContextVar`` cannot cross the server's
|
||||
task boundary -- see the plan's R2), and the non-device tools (``read_note``,
|
||||
``list_notes``, ``ask_explorer``).
|
||||
|
||||
Status is carried by ``ActionResult.ok`` -- the historical substring sniffing on
|
||||
``"Error"``/``"Failed"`` is gone, so typing the literal text "Failed" into a field no
|
||||
longer marks the step as failed, and a successful tap with a failed screenshot is no
|
||||
longer misreported as an error.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from artemis.config import resolve_explorer_version
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.data_engine.trace import TraceSpan
|
||||
from artemis.mcp.action_manifest import OPTIONAL_ACTIONS, REQUIRED_ACTIONS
|
||||
from artemis.mcp.action_session import ActionSession, get_action_session
|
||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||
from artemis.mcp.actuators.adb import AdbActuator
|
||||
from artemis.agents.validator.tool_declarations import (
|
||||
ToolExecutionResult,
|
||||
normalize_coordinate_target,
|
||||
)
|
||||
from artemis.utils.coordinates import (
|
||||
compute_smart_swipe_coordinates,
|
||||
parse_swipe_parameters,
|
||||
)
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.notes import (
|
||||
format_list_notes_failure,
|
||||
format_list_notes_success,
|
||||
format_read_note_failure,
|
||||
list_notes_info,
|
||||
read_note_content,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["McpActionExecutor"]
|
||||
|
||||
# Wording for wrapped exceptions; matches the historical executor `except` arms.
|
||||
_EXCEPTION_PREFIX = {
|
||||
"click": "Error during click",
|
||||
"click_sequence": "Error executing click sequence",
|
||||
"long_press": "Error during long press",
|
||||
"input_text": "Error during input text",
|
||||
"swipe": "Error during swipe",
|
||||
"press_key": "Error during press_key",
|
||||
"manage_app": "Error during manage_app",
|
||||
"wait_for_delay": "Error during wait_for_delay",
|
||||
"wait_for_text": "Error during wait_for_text",
|
||||
}
|
||||
|
||||
|
||||
class _ArgError(ValueError):
|
||||
"""Argument-translation failure whose message is already fully formatted."""
|
||||
|
||||
|
||||
class McpActionExecutor:
|
||||
"""Routes agent tool calls through the unified action MCP session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: ArtemisContext,
|
||||
controller: UnifiedMobileController | None = None,
|
||||
actuator: AdbActuator | None = None,
|
||||
):
|
||||
self.ctx = ctx
|
||||
self.actuator = (
|
||||
actuator or getattr(ctx, "actuator", None) or AdbActuator(ctx, controller)
|
||||
)
|
||||
self.controller = self.actuator.controller
|
||||
self._session: ActionSession | None = None
|
||||
|
||||
@property
|
||||
def action_tool_names(self) -> frozenset[str]:
|
||||
"""Device actions plus backend extension names -- the dynamic dispatch set."""
|
||||
return (
|
||||
(REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
|
||||
| {e.name for e in self.actuator.extensions()}
|
||||
)
|
||||
|
||||
async def _session_or_start(self) -> ActionSession:
|
||||
if self._session is None or not self._session.started:
|
||||
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
||||
return self._session
|
||||
|
||||
# --- Public entry ----------------------------------------------------------------
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
name: str,
|
||||
args: dict[str, Any],
|
||||
tool_call_id: str,
|
||||
state: Any,
|
||||
) -> ToolExecutionResult:
|
||||
"""Executes the named tool and wraps the outcome for the calling agent."""
|
||||
raw_name = name.split(":")[-1] if ":" in name else name
|
||||
|
||||
if raw_name in ("read_note", "list_notes", "ask_explorer"):
|
||||
return await self._execute_agent_tool(raw_name, name, args, tool_call_id, state)
|
||||
|
||||
if raw_name in self.action_tool_names:
|
||||
return await self._execute_device_action(
|
||||
raw_name, name, args, tool_call_id, state
|
||||
)
|
||||
|
||||
return ToolExecutionResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=name,
|
||||
status="error",
|
||||
text_summary=f"Error: Tool '{name}' not supported.",
|
||||
)
|
||||
|
||||
# --- Device actions --------------------------------------------------------------
|
||||
|
||||
async def _execute_device_action(
|
||||
self,
|
||||
raw_name: str,
|
||||
name: str,
|
||||
args: dict[str, Any],
|
||||
tool_call_id: str,
|
||||
state: Any,
|
||||
) -> ToolExecutionResult:
|
||||
span = TraceSpan(name=raw_name, trace_type="action", ctx=self.ctx)
|
||||
span.payload = {"args": args}
|
||||
|
||||
message = ""
|
||||
res: ActionResult | None = None
|
||||
img_bytes: bytes | None = None
|
||||
shot_path: str | None = None
|
||||
xml_list: str | None = None
|
||||
|
||||
with span:
|
||||
try:
|
||||
session = await self._session_or_start()
|
||||
wire_name, wire_args, finalize = self._translate(raw_name, args, state)
|
||||
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
|
||||
|
||||
if extension:
|
||||
raw_result = await session.call_raw(wire_name, wire_args)
|
||||
message = next(
|
||||
(b.text for b in raw_result.content if getattr(b, "type", "") == "text"),
|
||||
f"Extension tool '{raw_name}' completed.",
|
||||
)
|
||||
res = ActionResult.success(raw_name, message)
|
||||
else:
|
||||
res = await session.call(wire_name, wire_args)
|
||||
message = finalize(res) if finalize else res.message
|
||||
|
||||
if res.ok or self._observe_despite_failure(raw_name, res):
|
||||
obs = await session.observe(settle_ms=400)
|
||||
if obs.ok:
|
||||
shot_path = obs.screenshot_path
|
||||
xml_list = obs.elements_text
|
||||
if obs.hierarchy_ok:
|
||||
state.indexed_points = [el["center"] for el in obs.elements]
|
||||
state.indexed_elements = obs.elements
|
||||
if shot_path:
|
||||
try:
|
||||
img_bytes = Path(shot_path).read_bytes()
|
||||
except Exception as read_err:
|
||||
logger.warning(
|
||||
f"Failed to read observed screenshot: {read_err}"
|
||||
)
|
||||
except _ArgError as e:
|
||||
res = ActionResult.failure(raw_name, str(e), code=ActionCode.INVALID_ARGS)
|
||||
message = str(e)
|
||||
except Exception as e:
|
||||
prefix = _EXCEPTION_PREFIX.get(raw_name, f"Error during {raw_name}")
|
||||
message = f"{prefix}: {e}"
|
||||
res = ActionResult.failure(raw_name, message, detail=repr(e))
|
||||
|
||||
if shot_path:
|
||||
state.latest_screenshot = shot_path
|
||||
|
||||
status = "success" if res.ok else "error"
|
||||
post_image_name = None
|
||||
if shot_path:
|
||||
post_image_name = Path(shot_path).stem
|
||||
elif img_bytes:
|
||||
post_image_name = hashlib.sha256(img_bytes).hexdigest()
|
||||
span.result = {
|
||||
"outcome": message,
|
||||
"post_image_name": post_image_name,
|
||||
"has_xml": bool(xml_list),
|
||||
"status": status,
|
||||
}
|
||||
if status == "error":
|
||||
span.status = "failed"
|
||||
span.error = message
|
||||
|
||||
return ToolExecutionResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=name,
|
||||
status=status,
|
||||
text_summary=message,
|
||||
screenshot_bytes=img_bytes,
|
||||
screenshot_path=shot_path,
|
||||
ui_elements_text=xml_list,
|
||||
metadata={
|
||||
"code": res.code.value,
|
||||
"normalized_coordinates": res.normalized_coordinates,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _observe_despite_failure(raw_name: str, res: ActionResult) -> bool:
|
||||
"""Failures that still observe the screen, mirroring the legacy executor.
|
||||
|
||||
A failed app launch and a wait_for_text timeout captured the screen; argument
|
||||
and package errors returned without one.
|
||||
"""
|
||||
if raw_name == "manage_app":
|
||||
return res.code not in (ActionCode.PACKAGE_NOT_FOUND, ActionCode.INVALID_ARGS)
|
||||
if raw_name == "wait_for_text":
|
||||
return res.code is ActionCode.TIMEOUT
|
||||
return False
|
||||
|
||||
# --- Argument translation --------------------------------------------------------
|
||||
|
||||
def _translate(
|
||||
self, raw_name: str, args: dict[str, Any], state: Any
|
||||
) -> tuple[str, dict[str, Any], Any]:
|
||||
"""Maps agent-facing args to wire args; returns (name, args, finalize).
|
||||
|
||||
``finalize(res)`` post-processes the ActionResult message where the historical
|
||||
wording depended on client-side context (direction swipes).
|
||||
"""
|
||||
if raw_name == "click":
|
||||
target = self._require_pair(
|
||||
args.get("target") or args.get("coordinates") or [], "click"
|
||||
)
|
||||
return (
|
||||
"click",
|
||||
{
|
||||
"target": list(target),
|
||||
"times": args.get("times", 1),
|
||||
"delay_ms": args.get("delay_ms", 100),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
if raw_name == "long_press":
|
||||
target = self._require_pair(
|
||||
args.get("target") or args.get("coordinates") or [], "long press"
|
||||
)
|
||||
return (
|
||||
"long_press",
|
||||
{
|
||||
"target": list(target),
|
||||
"duration_ms": args.get("duration_ms", args.get("duration", 1000)),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
if raw_name == "input_text":
|
||||
raw_target = args.get("target") or args.get("coordinates")
|
||||
target = None
|
||||
if raw_target:
|
||||
target = list(self._require_pair(raw_target, "input text"))
|
||||
return (
|
||||
"input_text",
|
||||
{
|
||||
"text": args.get("text", ""),
|
||||
"target": target,
|
||||
"clear_exist": args.get("clear_exist", True),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
if raw_name == "click_sequence":
|
||||
return (
|
||||
"click_sequence",
|
||||
{
|
||||
"sequence": self._resolve_sequence(args.get("sequence") or [], state),
|
||||
"delay_ms": args.get("delay_ms", 50),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
if raw_name == "swipe":
|
||||
return self._translate_swipe(args, state)
|
||||
|
||||
if raw_name == "press_key":
|
||||
return "press_key", {"key": args.get("key", "BACK")}, None
|
||||
|
||||
if raw_name == "manage_app":
|
||||
return (
|
||||
"manage_app",
|
||||
{
|
||||
"action": args.get("action", "launch"),
|
||||
"app_name": args.get("app_name", ""),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
if raw_name == "wait_for_delay":
|
||||
return "wait_for_delay", {"time_in_ms": args.get("time_in_ms", 1000)}, None
|
||||
|
||||
if raw_name == "wait_for_text":
|
||||
return (
|
||||
"wait_for_text",
|
||||
{
|
||||
"text": args.get("text", ""),
|
||||
"wait_state": args.get("wait_state") or "appear",
|
||||
"timeout_ms": args.get("timeout_ms", 5000),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
# Backend extension: pass the arguments straight through.
|
||||
return raw_name, dict(args), None
|
||||
|
||||
@staticmethod
|
||||
def _require_pair(raw: Any, label: str) -> tuple[int, int]:
|
||||
target = normalize_coordinate_target(raw)
|
||||
if not isinstance(target, (list, tuple)) or len(target) != 2:
|
||||
prefix = {
|
||||
"click": "Error during click",
|
||||
"long press": "Error during long press",
|
||||
"input text": "Error during input text",
|
||||
}[label]
|
||||
raise _ArgError(f"{prefix}: Invalid target format: {raw}")
|
||||
return int(target[0]), int(target[1])
|
||||
|
||||
def _resolve_sequence(self, sequence: Any, state: Any) -> list[list[int]]:
|
||||
"""Resolves click_sequence entries (indices or pairs) to normalized pairs."""
|
||||
if isinstance(sequence, str):
|
||||
sequence_str = sequence.strip()
|
||||
try:
|
||||
sequence = json.loads(sequence_str)
|
||||
except Exception:
|
||||
try:
|
||||
sequence = ast.literal_eval(sequence_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not isinstance(sequence, (list, tuple)):
|
||||
raise _ArgError(
|
||||
f"Error during click sequence: Invalid sequence format: {sequence}"
|
||||
)
|
||||
|
||||
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
|
||||
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
|
||||
|
||||
resolved: list[list[int]] = []
|
||||
for raw_target in sequence:
|
||||
target = normalize_coordinate_target(raw_target)
|
||||
if isinstance(target, int):
|
||||
idx = target
|
||||
points = getattr(state, "indexed_points", []) or []
|
||||
if not 1 <= idx <= len(points):
|
||||
raise _ArgError(
|
||||
"Error during click sequence: Index"
|
||||
f" {idx} is out of range (available: {len(points)})"
|
||||
)
|
||||
px, py = int(points[idx - 1][0]), int(points[idx - 1][1])
|
||||
nx = int(max(0, min(1000, round(px * 1000 / max(1, width)))))
|
||||
ny = int(max(0, min(1000, round(py * 1000 / max(1, height)))))
|
||||
elif isinstance(target, (list, tuple)) and len(target) == 2:
|
||||
nx, ny = int(target[0]), int(target[1])
|
||||
else:
|
||||
raise _ArgError(
|
||||
f"Error during click sequence: Invalid target format: {raw_target}"
|
||||
)
|
||||
resolved.append([nx, ny])
|
||||
return resolved
|
||||
|
||||
def _translate_swipe(
|
||||
self, args: dict[str, Any], state: Any
|
||||
) -> tuple[str, dict[str, Any], Any]:
|
||||
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
|
||||
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
|
||||
default_duration = args.get("duration", 400)
|
||||
kind, target, final_duration = parse_swipe_parameters(
|
||||
dict(args), default_duration=default_duration
|
||||
)
|
||||
|
||||
if kind == "direction":
|
||||
dir_name = target
|
||||
x1, y1, x2, y2, smart_dur = compute_smart_swipe_coordinates(
|
||||
direction=target,
|
||||
target=args.get("target"),
|
||||
indexed_elements=getattr(state, "indexed_elements", None) if state else None,
|
||||
ui_hierarchy=getattr(state, "ui_tree", None) if state else None,
|
||||
width=width,
|
||||
height=height,
|
||||
duration=final_duration,
|
||||
)
|
||||
|
||||
def _norm(v: float, size: int) -> int:
|
||||
return int(max(0, min(1000, round(v * 1000 / max(1, size)))))
|
||||
|
||||
def finalize(res: ActionResult) -> str:
|
||||
if not res.ok:
|
||||
return f"Error swiping {dir_name}: {res.detail}"
|
||||
return f"Swipe completed successfully. Swiped {dir_name}."
|
||||
|
||||
return (
|
||||
"swipe",
|
||||
{
|
||||
"start": [_norm(x1, width), _norm(y1, height)],
|
||||
"end": [_norm(x2, width), _norm(y2, height)],
|
||||
"duration_ms": smart_dur,
|
||||
},
|
||||
finalize,
|
||||
)
|
||||
|
||||
if kind == "coords":
|
||||
x1, y1, x2, y2 = target
|
||||
|
||||
def finalize(res: ActionResult) -> str:
|
||||
if not res.ok:
|
||||
return f"Error dragging: {res.detail}"
|
||||
return (
|
||||
"Swipe completed successfully. Swiped from"
|
||||
f" [{x1}, {y1}] to [{x2}, {y2}]."
|
||||
)
|
||||
|
||||
return (
|
||||
"swipe",
|
||||
{
|
||||
"start": [int(x1), int(y1)],
|
||||
"end": [int(x2), int(y2)],
|
||||
"duration_ms": final_duration,
|
||||
},
|
||||
finalize,
|
||||
)
|
||||
|
||||
raise _ArgError(f"Error during swipe: Invalid direction: {args}")
|
||||
|
||||
# --- Non-device tools (stay agent-side) ------------------------------------------
|
||||
|
||||
async def _execute_agent_tool(
|
||||
self,
|
||||
raw_name: str,
|
||||
name: str,
|
||||
args: dict[str, Any],
|
||||
tool_call_id: str,
|
||||
state: Any,
|
||||
) -> ToolExecutionResult:
|
||||
span = TraceSpan(name=raw_name, trace_type="tool", ctx=self.ctx)
|
||||
span.payload = {"args": args}
|
||||
with span:
|
||||
if raw_name == "read_note":
|
||||
text = await self._read_note(
|
||||
args.get("key", ""), args.get("start_line"), args.get("end_line")
|
||||
)
|
||||
elif raw_name == "list_notes":
|
||||
text = await self._list_notes()
|
||||
else:
|
||||
text = await self._ask_explorer(
|
||||
args.get("query") or args.get("task_description") or "",
|
||||
args.get("context_feedback"),
|
||||
state,
|
||||
)
|
||||
span.result = {"outcome": text}
|
||||
|
||||
# These texts come from our own failure formatters, so the historical
|
||||
# "Error" marker check is reliable here (unlike free-form device output).
|
||||
return ToolExecutionResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=name,
|
||||
status="error" if "Error" in (text or "") else "success",
|
||||
text_summary=text,
|
||||
)
|
||||
|
||||
async def _read_note(
|
||||
self, key: str, start_line: int | None, end_line: int | None
|
||||
) -> str:
|
||||
try:
|
||||
base_dir = self.ctx.data_engine.base_dir if self.ctx.data_engine else None
|
||||
if not base_dir:
|
||||
return format_read_note_failure(key, "DataEngine not initialized.")
|
||||
return read_note_content(base_dir, key, start_line, end_line)
|
||||
except Exception as e:
|
||||
return format_read_note_failure(key, str(e))
|
||||
|
||||
async def _list_notes(self) -> str:
|
||||
try:
|
||||
base_dir = self.ctx.data_engine.base_dir if self.ctx.data_engine else None
|
||||
if not base_dir:
|
||||
return format_list_notes_failure("DataEngine not initialized.")
|
||||
return format_list_notes_success(list_notes_info(base_dir))
|
||||
except Exception as e:
|
||||
return format_list_notes_failure(str(e))
|
||||
|
||||
async def _ask_explorer(
|
||||
self, query: str, context_feedback: str | None, state: Any
|
||||
) -> str:
|
||||
# Imported lazily: the explorer stack is heavy and recursive (it spawns an LLM
|
||||
# sub-agent), which is also why it must never live behind the action server.
|
||||
from artemis.tools.explorer_tool import _run_explorer_logic
|
||||
|
||||
try:
|
||||
active_version = resolve_explorer_version(
|
||||
self.ctx,
|
||||
explicit_version=None,
|
||||
agent_or_profile_name="validator",
|
||||
)
|
||||
result = await _run_explorer_logic(
|
||||
self.ctx,
|
||||
state,
|
||||
query,
|
||||
context_feedback or "",
|
||||
version=active_version,
|
||||
)
|
||||
if isinstance(result, list):
|
||||
texts = [
|
||||
item["text"]
|
||||
for item in result
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
]
|
||||
return "\n".join(texts) if texts else str(result)
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return f"Error executing ask_explorer: {e}"
|
||||
@@ -0,0 +1,319 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Contract layer between actuator backends and the agent-facing tool surface.
|
||||
|
||||
This module is the single place that declares *what a backend must provide*. It sits
|
||||
between the actuator implementations (``artemis/mcp/actuators/``) and the agents that
|
||||
consume tools, so that a backend -- ADB today, a robot arm tomorrow -- can omit tools
|
||||
it cannot perform, and add tools nobody anticipated, without any agent knowing.
|
||||
|
||||
Two rules govern the split between required and optional:
|
||||
|
||||
1. A tool is REQUIRED only when a prompt depends on it *structurally*, i.e. the prompt
|
||||
cannot be assembled around its absence. Today that is ``click_sequence`` alone: the
|
||||
two ``*_disappeared_analyzer.md`` prompts are built around "atomic chained execution
|
||||
to defeat turn latency", which *is* ``click_sequence``.
|
||||
2. Everything else is OPTIONAL, because every other prompt reference lives in a tool
|
||||
enumeration slot or a self-contained instruction block that the assembly layer
|
||||
(``artemis/agents/prompt_assembly.py``) renders conditionally. An optional tool that
|
||||
a backend does not implement disappears from the declarations *and* from the prompt,
|
||||
so it costs the model nothing.
|
||||
|
||||
``tests/unit/mcp/test_action_manifest.py`` re-derives rule 2 by scanning the prompt
|
||||
sources, so adding a tool name to a prompt without classifying it here fails CI.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"ActuatorContractError",
|
||||
"AGENT_ROLES",
|
||||
"ALL_KNOWN_TOOLS",
|
||||
"BACKEND_INDEPENDENT_TOOLS",
|
||||
"DEVICE_ACTIONS",
|
||||
"ExtensionTool",
|
||||
"INTERNAL_ACTIONS",
|
||||
"MIN_EXTENSION_DESCRIPTION_LENGTH",
|
||||
"OPTIONAL_ACTIONS",
|
||||
"REQUIRED_ACTIONS",
|
||||
"available_device_actions",
|
||||
"filter_declarations",
|
||||
"validate_actuator",
|
||||
]
|
||||
|
||||
|
||||
# --- Tool classification -------------------------------------------------------------
|
||||
|
||||
#: Device actions a backend MUST implement. See rule 1 in the module docstring.
|
||||
#:
|
||||
#: ``click_sequence`` implies the ability to tap, so a backend providing it can
|
||||
#: trivially provide ``click`` as well; ``click`` is nonetheless optional because no
|
||||
#: prompt depends on it structurally once the enumeration slots are assembled.
|
||||
REQUIRED_ACTIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
"click_sequence",
|
||||
}
|
||||
)
|
||||
|
||||
#: Device actions a backend MAY implement. Absent ones vanish from both the tool
|
||||
#: declarations and the rendered prompts.
|
||||
OPTIONAL_ACTIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
"click",
|
||||
"long_press",
|
||||
"input_text",
|
||||
"swipe",
|
||||
"press_key",
|
||||
"manage_app",
|
||||
"wait_for_delay",
|
||||
# The four below appear in no prompt at all. `wait_for_text` corroborates the
|
||||
# classification: it is listed in the legacy ACTION_TOOL_NAMES but has never had
|
||||
# a ToolDeclaration, i.e. it has always been unreachable by any LLM.
|
||||
"wait_for_text",
|
||||
"open_link",
|
||||
"erase_one_char",
|
||||
"focus_and_clear_text",
|
||||
}
|
||||
)
|
||||
|
||||
#: Used by the client-side adapter only; never declared to any LLM, so the
|
||||
#: "no cognitive load" rule does not apply. The adapter cannot function without them,
|
||||
#: so they are validated as strictly as REQUIRED_ACTIONS.
|
||||
INTERNAL_ACTIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
"observe_screen",
|
||||
"take_screenshot",
|
||||
"get_ui_hierarchy",
|
||||
}
|
||||
)
|
||||
|
||||
#: Tools that are not device control at all -- notes, sub-agents, diagnostics, and the
|
||||
#: loop-termination sentinels. They are unaffected by which actuator is installed.
|
||||
#:
|
||||
#: ``run_adb_command`` and ``manage_app`` ride the *system channel* rather than physical
|
||||
#: actuation: a robot arm driving a handset still has ADB attached, so ``am start``
|
||||
#: suffices and no icon-hunting is required. (``manage_app`` is nonetheless classified
|
||||
#: as an optional device action above, since it is declared through the same path.)
|
||||
BACKEND_INDEPENDENT_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
"read_note",
|
||||
"list_notes",
|
||||
"save_note",
|
||||
"update_note",
|
||||
"append_note",
|
||||
"ask_explorer",
|
||||
"ask_diagnoser",
|
||||
"ask_committee",
|
||||
"video_analyzer",
|
||||
"run_adb_command",
|
||||
"manage_task",
|
||||
"analyze_task_output",
|
||||
"reply_to_checker",
|
||||
"report_task_status",
|
||||
"report_failure_analysis",
|
||||
}
|
||||
)
|
||||
|
||||
#: Everything an actuator may legitimately claim in ``capabilities()``.
|
||||
DEVICE_ACTIONS: frozenset[str] = REQUIRED_ACTIONS | OPTIONAL_ACTIONS | INTERNAL_ACTIONS
|
||||
|
||||
#: Every name the manifest knows about. Extension tools must not collide with these.
|
||||
ALL_KNOWN_TOOLS: frozenset[str] = DEVICE_ACTIONS | BACKEND_INDEPENDENT_TOOLS
|
||||
|
||||
#: Agent roles an extension tool may target.
|
||||
AGENT_ROLES: frozenset[str] = frozenset(
|
||||
{
|
||||
"operator",
|
||||
"flash",
|
||||
"failure_analyzer",
|
||||
"validator",
|
||||
}
|
||||
)
|
||||
|
||||
#: Extension descriptions carry the entire usage contract (see ``ExtensionTool``), so a
|
||||
#: one-word description is a bug rather than a terse style choice.
|
||||
MIN_EXTENSION_DESCRIPTION_LENGTH = 40
|
||||
|
||||
|
||||
class ActuatorContractError(RuntimeError):
|
||||
"""Raised when an actuator does not satisfy the manifest contract.
|
||||
|
||||
Always raised at session start rather than mid-run, so a misconfigured backend
|
||||
fails before the agent has taken any action on the device.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionTool:
|
||||
"""A tool contributed by a backend that the manifest does not know about.
|
||||
|
||||
Extension tools are invisible to the prompt layer by construction: prompts are
|
||||
static text that cannot anticipate future backends, so an extension gets **no**
|
||||
prompt-level teaching. Its ``description`` must therefore be self-contained --
|
||||
stating when to reach for it and what it does -- or the model will never use it
|
||||
correctly. :func:`validate_actuator` enforces a length floor as a crude guard.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
handler: Callable[..., Awaitable[Any]]
|
||||
targets: frozenset[str] = field(default=AGENT_ROLES)
|
||||
|
||||
|
||||
# --- Contract validation -------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_actuator(actuator: Any) -> None:
|
||||
"""Checks an actuator against the manifest, raising on any violation.
|
||||
|
||||
Called once from ``get_action_session`` before the first device interaction.
|
||||
"""
|
||||
caps = frozenset(actuator.capabilities())
|
||||
|
||||
missing_required = REQUIRED_ACTIONS - caps
|
||||
if missing_required:
|
||||
raise ActuatorContractError(
|
||||
f"{type(actuator).__name__} does not implement required action(s): "
|
||||
f"{sorted(missing_required)}. These are structurally depended on by prompts "
|
||||
f"and cannot be assembled away."
|
||||
)
|
||||
|
||||
missing_internal = INTERNAL_ACTIONS - caps
|
||||
if missing_internal:
|
||||
raise ActuatorContractError(
|
||||
f"{type(actuator).__name__} does not implement internal action(s): "
|
||||
f"{sorted(missing_internal)}. The client-side adapter cannot observe the "
|
||||
f"device without them."
|
||||
)
|
||||
|
||||
unknown = caps - DEVICE_ACTIONS
|
||||
if unknown:
|
||||
raise ActuatorContractError(
|
||||
f"{type(actuator).__name__} claims capabilities unknown to the manifest: "
|
||||
f"{sorted(unknown)}. Backend-specific tools belong in extensions(), not "
|
||||
f"capabilities()."
|
||||
)
|
||||
|
||||
_validate_extensions(actuator)
|
||||
|
||||
|
||||
def _validate_extensions(actuator: Any) -> None:
|
||||
"""Validates extension tools for name collisions and self-sufficient descriptions."""
|
||||
extensions = list(actuator.extensions())
|
||||
seen: set[str] = set()
|
||||
|
||||
for ext in extensions:
|
||||
if ext.name in ALL_KNOWN_TOOLS:
|
||||
raise ActuatorContractError(
|
||||
f"Extension tool '{ext.name}' collides with a manifest tool of the same "
|
||||
f"name. Extension names must be unique across the whole tool surface."
|
||||
)
|
||||
if ext.name in seen:
|
||||
raise ActuatorContractError(
|
||||
f"{type(actuator).__name__} registers extension '{ext.name}' more than once."
|
||||
)
|
||||
seen.add(ext.name)
|
||||
|
||||
unknown_targets = frozenset(ext.targets) - AGENT_ROLES
|
||||
if unknown_targets:
|
||||
raise ActuatorContractError(
|
||||
f"Extension tool '{ext.name}' targets unknown agent role(s): "
|
||||
f"{sorted(unknown_targets)}. Valid roles: {sorted(AGENT_ROLES)}."
|
||||
)
|
||||
|
||||
if len(ext.description.strip()) < MIN_EXTENSION_DESCRIPTION_LENGTH:
|
||||
raise ActuatorContractError(
|
||||
f"Extension tool '{ext.name}' has a description of "
|
||||
f"{len(ext.description.strip())} characters. Extensions receive no "
|
||||
f"prompt-level guidance, so the description must state when to use the "
|
||||
f"tool and what it does (minimum "
|
||||
f"{MIN_EXTENSION_DESCRIPTION_LENGTH} characters)."
|
||||
)
|
||||
|
||||
|
||||
# --- Surface derivation --------------------------------------------------------------
|
||||
|
||||
|
||||
def available_device_actions(actuator: Any) -> frozenset[str]:
|
||||
"""Returns the LLM-facing device actions this actuator supports.
|
||||
|
||||
Internal actions are excluded: they are adapter plumbing and never reach a model.
|
||||
"""
|
||||
caps = frozenset(actuator.capabilities())
|
||||
return (REQUIRED_ACTIONS | OPTIONAL_ACTIONS) & caps
|
||||
|
||||
|
||||
def filter_declarations(
|
||||
declarations: list[Any],
|
||||
actuator: Any,
|
||||
agent: str,
|
||||
) -> list[Any]:
|
||||
"""Filters a declaration list down to what ``actuator`` actually provides.
|
||||
|
||||
Declarations for known tools keep coming from the hand-tuned ``ToolDeclaration``
|
||||
constants -- this layer only removes entries, it never rewrites descriptions.
|
||||
Extension declarations are appended from the actuator itself, since the manifest
|
||||
cannot know them in advance.
|
||||
|
||||
Args:
|
||||
declarations: Declarations the agent would use if every tool existed.
|
||||
actuator: The installed backend.
|
||||
agent: One of :data:`AGENT_ROLES`; selects which extensions apply.
|
||||
|
||||
Returns:
|
||||
Declarations in their original order, with unavailable device actions dropped
|
||||
and this agent's extension tools appended.
|
||||
"""
|
||||
if agent not in AGENT_ROLES:
|
||||
raise ValueError(f"Unknown agent role '{agent}'. Valid roles: {sorted(AGENT_ROLES)}.")
|
||||
|
||||
available = available_device_actions(actuator)
|
||||
|
||||
kept = [
|
||||
decl
|
||||
for decl in declarations
|
||||
# Only device actions are gated. Backend-independent tools and anything the
|
||||
# manifest does not classify (e.g. an agent's own bespoke tool) pass through.
|
||||
if _declaration_name(decl) not in DEVICE_ACTIONS
|
||||
or _declaration_name(decl) in available
|
||||
]
|
||||
|
||||
for ext in actuator.extensions():
|
||||
if agent in ext.targets:
|
||||
kept.append(_extension_declaration(ext))
|
||||
|
||||
return kept
|
||||
|
||||
|
||||
def _declaration_name(declaration: Any) -> str:
|
||||
"""Reads a tool name off either a ToolDeclaration mapping or a LangChain tool."""
|
||||
name = getattr(declaration, "name", None)
|
||||
if name is None and isinstance(declaration, dict):
|
||||
name = declaration.get("name")
|
||||
return name or ""
|
||||
|
||||
|
||||
def _extension_declaration(ext: ExtensionTool) -> Any:
|
||||
"""Renders an ExtensionTool into the same ToolDeclaration shape as known tools."""
|
||||
from artemis.core.tool_declaration import ToolDeclaration
|
||||
|
||||
return ToolDeclaration(
|
||||
name=ext.name,
|
||||
description=ext.description,
|
||||
parameters=ext.input_schema,
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""The one place where the historical verb split is resolved.
|
||||
|
||||
Canonical tool names are the agent-facing ones (``click``, ``long_press``,
|
||||
``input_text``, ``manage_app``, ...) because those are what the prompts teach and the
|
||||
prompts are the contract. The Operator/Validator-internal vocabulary
|
||||
(``tap``, ``long_press_on``, ``focus_and_input_text``, ``launch_app``/``stop_app``,
|
||||
``back``) is translated here and nowhere else.
|
||||
|
||||
``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.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["OPERATOR_ACTION_TO_CANONICAL", "to_canonical_call"]
|
||||
|
||||
#: Operator-internal action verb -> canonical tool name.
|
||||
OPERATOR_ACTION_TO_CANONICAL: dict[str, str] = {
|
||||
"tap": "click",
|
||||
"long_press_on": "long_press",
|
||||
"swipe": "swipe",
|
||||
"focus_and_input_text": "input_text",
|
||||
"focus_and_clear_text": "focus_and_clear_text",
|
||||
"erase_one_char": "erase_one_char",
|
||||
"press_key": "press_key",
|
||||
"back": "press_key",
|
||||
"launch_app": "manage_app",
|
||||
"stop_app": "manage_app",
|
||||
"open_link": "open_link",
|
||||
"wait_for_delay": "wait_for_delay",
|
||||
}
|
||||
|
||||
|
||||
def _norm_pair(
|
||||
action_item: dict[str, Any], dims: tuple[int, int] | None = None
|
||||
) -> tuple[int, int] | None:
|
||||
"""Reads the Operator-computed normalized [nx, ny] off an action item.
|
||||
|
||||
Falls back to converting the pixel ``coordinates`` when the item predates the
|
||||
``normalized_coordinates`` field and screen ``dims`` are provided.
|
||||
"""
|
||||
coords = action_item.get("normalized_coordinates")
|
||||
if isinstance(coords, (list, tuple)) and len(coords) >= 2:
|
||||
return int(coords[0]), int(coords[1])
|
||||
|
||||
pixel = action_item.get("coordinates")
|
||||
if dims and isinstance(pixel, (list, tuple)) and len(pixel) >= 2:
|
||||
width, height = dims
|
||||
nx = int(max(0, min(1000, round(int(pixel[0]) * 1000 / max(1, width)))))
|
||||
ny = int(max(0, min(1000, round(int(pixel[1]) * 1000 / max(1, height)))))
|
||||
return nx, ny
|
||||
return None
|
||||
|
||||
|
||||
def to_canonical_call(
|
||||
action_item: dict[str, Any], dims: tuple[int, int] | None = None
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Translates a Validator action item into a canonical (tool, kwargs) call.
|
||||
|
||||
Args:
|
||||
action_item: One entry of the Operator's structured decisions.
|
||||
dims: Optional (width, height) for pixel->normalized fallback on legacy items
|
||||
lacking ``normalized_coordinates``.
|
||||
|
||||
Raises:
|
||||
ValueError: for an unknown action verb or an item missing the coordinates the
|
||||
canonical call requires.
|
||||
"""
|
||||
verb = str(action_item.get("action", ""))
|
||||
canonical = OPERATOR_ACTION_TO_CANONICAL.get(verb)
|
||||
if canonical is None:
|
||||
raise ValueError(f"Unsupported action: {verb}")
|
||||
|
||||
if verb == "tap":
|
||||
pair = _norm_pair(action_item, dims)
|
||||
if pair is None:
|
||||
raise ValueError("Invalid coords")
|
||||
return "click", {
|
||||
"target": [pair[0], pair[1]],
|
||||
"times": action_item.get("times") or action_item.get("click_times") or 1,
|
||||
"delay_ms": action_item.get("delay_ms") or action_item.get("delay") or 100,
|
||||
}
|
||||
|
||||
if verb == "long_press_on":
|
||||
pair = _norm_pair(action_item, dims)
|
||||
if pair is None:
|
||||
raise ValueError("Invalid coords")
|
||||
return "long_press", {
|
||||
"target": [pair[0], pair[1]],
|
||||
"duration_ms": action_item.get("duration", 1000),
|
||||
}
|
||||
|
||||
if verb == "swipe":
|
||||
coords = action_item.get("normalized_coordinates")
|
||||
if not (isinstance(coords, (list, tuple)) and len(coords) == 4):
|
||||
pixel = action_item.get("coordinates")
|
||||
if dims and isinstance(pixel, (list, tuple)) and len(pixel) == 4:
|
||||
width, height = dims
|
||||
coords = [
|
||||
int(max(0, min(1000, round(int(pixel[0]) * 1000 / max(1, width))))),
|
||||
int(max(0, min(1000, round(int(pixel[1]) * 1000 / max(1, height))))),
|
||||
int(max(0, min(1000, round(int(pixel[2]) * 1000 / max(1, width))))),
|
||||
int(max(0, min(1000, round(int(pixel[3]) * 1000 / max(1, height))))),
|
||||
]
|
||||
else:
|
||||
raise ValueError("Invalid coords")
|
||||
return "swipe", {
|
||||
"start": [int(coords[0]), int(coords[1])],
|
||||
"end": [int(coords[2]), int(coords[3])],
|
||||
"duration_ms": action_item.get("duration", 400),
|
||||
}
|
||||
|
||||
if verb == "focus_and_input_text":
|
||||
pair = _norm_pair(action_item, dims)
|
||||
if pair is None:
|
||||
raise ValueError("Invalid coords")
|
||||
return "input_text", {
|
||||
"text": action_item.get("text", ""),
|
||||
"target": [pair[0], pair[1]],
|
||||
"clear_exist": bool(action_item.get("clear_before_input", False)),
|
||||
}
|
||||
|
||||
if verb == "focus_and_clear_text":
|
||||
pair = _norm_pair(action_item, dims)
|
||||
if pair is None:
|
||||
raise ValueError("Invalid coords")
|
||||
return "focus_and_clear_text", {"target": [pair[0], pair[1]]}
|
||||
|
||||
if verb == "erase_one_char":
|
||||
return "erase_one_char", {}
|
||||
|
||||
if verb == "press_key":
|
||||
keycode = str(action_item.get("keycode", ""))
|
||||
# Operator emits Android-style KEYCODE_* names; the canonical tool takes the
|
||||
# bare key word for the common keys. Anything else keeps its original spelling
|
||||
# so the driver can forward it verbatim (arbitrary KEYCODE_* / numeric codes).
|
||||
bare = keycode.removeprefix("KEYCODE_").lower() if keycode else ""
|
||||
known = {"home", "back", "enter", "delete", "tab", "search", "menu", "app_switch"}
|
||||
return "press_key", {"key": bare if bare in known else keycode}
|
||||
|
||||
if verb == "back":
|
||||
return "press_key", {"key": "back"}
|
||||
|
||||
if verb in ("launch_app", "stop_app"):
|
||||
return "manage_app", {
|
||||
"action": "launch" if verb == "launch_app" else "stop",
|
||||
"app_name": action_item.get("app_name", ""),
|
||||
}
|
||||
|
||||
if verb == "open_link":
|
||||
return "open_link", {"url": action_item.get("url", "")}
|
||||
|
||||
if verb == "wait_for_delay":
|
||||
return "wait_for_delay", {"time_in_ms": action_item.get("time_in_ms", 0)}
|
||||
|
||||
raise ValueError(f"Unsupported action: {verb}")
|
||||
@@ -0,0 +1,353 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""The unified device action MCP server.
|
||||
|
||||
``build_action_server`` turns an actuator into a ``FastMCP`` server that registers
|
||||
**only** the tools the actuator implements, plus its extension tools -- so
|
||||
``list_tools()`` is clean by construction and an absent optional tool is invisible to
|
||||
every consumer.
|
||||
|
||||
Return protocol: every device action returns a ``CallToolResult`` whose
|
||||
``structuredContent`` is an ``ActionResult`` -- ``ok``/``code`` carry the real status
|
||||
(killing the historical ``"Error" in text`` sniffing) while ``content`` keeps the
|
||||
historical human-readable message. ``isError`` is reserved for protocol-level
|
||||
failures; a device refusing an action is ``isError=False, ok=False`` so it reaches the
|
||||
model as an observation, not an exception.
|
||||
|
||||
Exceptions raised by the actuator are wrapped here with the historical
|
||||
``"Error during X: {e}"`` wording the agents' transcripts already contain.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated, Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import CallToolResult, ImageContent, TextContent
|
||||
|
||||
from artemis.mcp.action_manifest import validate_actuator
|
||||
from artemis.mcp.action_types import ActionCode, ActionResult, ObserveResult
|
||||
from artemis.mcp.actuators.base import Actuator
|
||||
from artemis.mcp.observation import observe as observe_impl
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["build_action_server"]
|
||||
|
||||
# Exception wrapping per tool; wording matches the historical executor `except` arms.
|
||||
_EXCEPTION_PREFIX = {
|
||||
"click": "Error during click",
|
||||
"click_sequence": "Error executing click sequence",
|
||||
"long_press": "Error during long press",
|
||||
"input_text": "Error during input text",
|
||||
"swipe": "Error during swipe",
|
||||
"press_key": "Error during press_key",
|
||||
"manage_app": "Error during manage_app",
|
||||
"wait_for_delay": "Error during wait_for_delay",
|
||||
"wait_for_text": "Error during wait_for_text",
|
||||
}
|
||||
|
||||
|
||||
def _wrap(res: ActionResult) -> CallToolResult:
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text=res.message)],
|
||||
structuredContent=res.model_dump(mode="json"),
|
||||
isError=False,
|
||||
)
|
||||
|
||||
|
||||
def _wrap_exception(action: str, exc: Exception) -> CallToolResult:
|
||||
prefix = _EXCEPTION_PREFIX.get(action, f"Error during {action}")
|
||||
return _wrap(
|
||||
ActionResult.failure(action, f"{prefix}: {exc}", detail=repr(exc))
|
||||
)
|
||||
|
||||
|
||||
def build_action_server(actuator: Actuator, name: str = "artemis_actions") -> FastMCP:
|
||||
"""Builds a FastMCP server exposing exactly what ``actuator`` implements.
|
||||
|
||||
Raises ``ActuatorContractError`` when the actuator violates the manifest, so a
|
||||
misconfigured backend fails before serving anything.
|
||||
"""
|
||||
validate_actuator(actuator)
|
||||
caps = actuator.capabilities()
|
||||
mcp = FastMCP(name)
|
||||
|
||||
# --- Device actions (registered only when implemented) ---------------------------
|
||||
|
||||
if "click" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def click(
|
||||
target: list[int], times: int = 1, delay_ms: int = 100
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Tap at a 0-1000 normalized [x, y] coordinate."""
|
||||
try:
|
||||
return _wrap(
|
||||
await actuator.click(
|
||||
int(target[0]), int(target[1]), times=times, delay_ms=delay_ms
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return _wrap_exception("click", e)
|
||||
|
||||
if "click_sequence" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def click_sequence(
|
||||
sequence: list[list[int]], delay_ms: int = 50
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Tap a series of 0-1000 normalized [x, y] points in one atomic burst."""
|
||||
try:
|
||||
points = [(int(p[0]), int(p[1])) for p in sequence]
|
||||
return _wrap(await actuator.click_sequence(points, delay_ms=delay_ms))
|
||||
except Exception as e:
|
||||
return _wrap_exception("click_sequence", e)
|
||||
|
||||
if "long_press" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def long_press(
|
||||
target: list[int], duration_ms: int = 1000
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Long-press at a 0-1000 normalized [x, y] coordinate."""
|
||||
try:
|
||||
return _wrap(
|
||||
await actuator.long_press(
|
||||
int(target[0]), int(target[1]), duration_ms=duration_ms
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return _wrap_exception("long_press", e)
|
||||
|
||||
if "input_text" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def input_text(
|
||||
text: str,
|
||||
target: list[int] | None = None,
|
||||
clear_exist: bool = True,
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Type text, optionally focusing a 0-1000 normalized [x, y] target first."""
|
||||
try:
|
||||
norm = (int(target[0]), int(target[1])) if target else None
|
||||
return _wrap(
|
||||
await actuator.input_text(text, norm, clear_exist=clear_exist)
|
||||
)
|
||||
except Exception as e:
|
||||
return _wrap_exception("input_text", e)
|
||||
|
||||
if "swipe" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def swipe(
|
||||
start: list[int], end: list[int], duration_ms: int = 800
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Swipe between two 0-1000 normalized [x, y] points."""
|
||||
try:
|
||||
return _wrap(
|
||||
await actuator.swipe(
|
||||
(int(start[0]), int(start[1])),
|
||||
(int(end[0]), int(end[1])),
|
||||
duration_ms,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return _wrap_exception("swipe", e)
|
||||
|
||||
if "press_key" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def press_key(key: str) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Press a device key (home, back, enter, delete, tab, search, menu, app_switch)."""
|
||||
try:
|
||||
return _wrap(await actuator.press_key(key))
|
||||
except Exception as e:
|
||||
return _wrap_exception("press_key", e)
|
||||
|
||||
if "manage_app" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def manage_app(
|
||||
action: str, app_name: str
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Launch or stop an app by human-readable name or package."""
|
||||
try:
|
||||
return _wrap(await actuator.manage_app(action, app_name))
|
||||
except Exception as e:
|
||||
return _wrap_exception("manage_app", e)
|
||||
|
||||
if "wait_for_delay" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def wait_for_delay(
|
||||
time_in_ms: int,
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Wait for a fixed number of milliseconds."""
|
||||
try:
|
||||
return _wrap(await actuator.wait_for_delay(time_in_ms))
|
||||
except Exception as e:
|
||||
return _wrap_exception("wait_for_delay", e)
|
||||
|
||||
if "wait_for_text" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def wait_for_text(
|
||||
text: str, wait_state: str = "appear", timeout_ms: int = 5000
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Wait for text to appear on or disappear from the screen."""
|
||||
try:
|
||||
return _wrap(await actuator.wait_for_text(text, wait_state, timeout_ms))
|
||||
except Exception as e:
|
||||
return _wrap_exception("wait_for_text", e)
|
||||
|
||||
if "open_link" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def open_link(url: str) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Open a URL on the device."""
|
||||
try:
|
||||
return _wrap(await actuator.open_link(url))
|
||||
except Exception as e:
|
||||
return _wrap_exception("open_link", e)
|
||||
|
||||
if "erase_one_char" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def erase_one_char() -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Erase a single character in the focused field."""
|
||||
try:
|
||||
return _wrap(await actuator.erase_one_char())
|
||||
except Exception as e:
|
||||
return _wrap_exception("erase_one_char", e)
|
||||
|
||||
if "focus_and_clear_text" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def focus_and_clear_text(
|
||||
target: list[int],
|
||||
) -> Annotated[CallToolResult, ActionResult]:
|
||||
"""Focus the field at a 0-1000 normalized [x, y] and clear its text."""
|
||||
try:
|
||||
return _wrap(
|
||||
await actuator.focus_and_clear_text(int(target[0]), int(target[1]))
|
||||
)
|
||||
except Exception as e:
|
||||
return _wrap_exception("focus_and_clear_text", e)
|
||||
|
||||
# --- Internal observation tools (never declared to an LLM) -----------------------
|
||||
|
||||
if "observe_screen" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def observe_screen(
|
||||
settle_ms: int = 400, include_image: bool = False
|
||||
) -> Annotated[CallToolResult, ObserveResult]:
|
||||
"""Capture and index the current screen (adapter-internal)."""
|
||||
try:
|
||||
obs, img_bytes = await observe_impl(
|
||||
getattr(actuator, "ctx", None),
|
||||
actuator.controller,
|
||||
settle_ms=settle_ms,
|
||||
)
|
||||
content: list[Any] = [TextContent(type="text", text=obs.message)]
|
||||
if include_image and img_bytes:
|
||||
import base64 as _b64
|
||||
|
||||
content.append(
|
||||
ImageContent(
|
||||
type="image",
|
||||
data=_b64.b64encode(img_bytes).decode("utf-8"),
|
||||
mimeType="image/jpeg",
|
||||
)
|
||||
)
|
||||
return CallToolResult(
|
||||
content=content,
|
||||
structuredContent=obs.model_dump(mode="json"),
|
||||
isError=False,
|
||||
)
|
||||
except Exception as e:
|
||||
obs = ObserveResult(
|
||||
ok=False,
|
||||
code=ActionCode.DEVICE_ERROR,
|
||||
message=f"Error during observe_screen: {e}",
|
||||
hierarchy_ok=False,
|
||||
)
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text=obs.message)],
|
||||
structuredContent=obs.model_dump(mode="json"),
|
||||
isError=False,
|
||||
)
|
||||
|
||||
# Timeouts on the two observation tools run INSIDE the tool, around the actuator
|
||||
# call only. They must never be expressed as a transport-level read timeout
|
||||
# (``read_timeout_seconds``) or a caller-side ``asyncio.wait_for``: both produce a
|
||||
# late response to an abandoned request, which crashes the mcp-1.29 client
|
||||
# receive loop (``_handle_response`` sends into the request's already-closed
|
||||
# response stream) and bricks the in-memory session for every later caller.
|
||||
# A server-side timeout yields a normal error payload and no late response.
|
||||
|
||||
if "take_screenshot" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def take_screenshot(timeout_ms: int | None = None) -> CallToolResult:
|
||||
"""Return the current screen as base64 (adapter-internal, no persistence)."""
|
||||
try:
|
||||
coro = actuator.take_screenshot()
|
||||
if timeout_ms:
|
||||
coro = asyncio.wait_for(coro, timeout=timeout_ms / 1000.0)
|
||||
b64 = await coro
|
||||
payload: dict[str, Any] = {"ok": True, "image_b64": b64}
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
payload = {"ok": False, "error": f"screenshot timed out after {timeout_ms}ms"}
|
||||
except Exception as e:
|
||||
payload = {"ok": False, "error": str(e)}
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text="screenshot" if payload["ok"] else str(payload))],
|
||||
structuredContent=payload,
|
||||
isError=False,
|
||||
)
|
||||
|
||||
if "get_ui_hierarchy" in caps:
|
||||
|
||||
@mcp.tool()
|
||||
async def get_ui_hierarchy(timeout_ms: int | None = None) -> CallToolResult:
|
||||
"""Return the raw UI element hierarchy (adapter-internal)."""
|
||||
try:
|
||||
coro = actuator.get_ui_elements()
|
||||
if timeout_ms:
|
||||
coro = asyncio.wait_for(coro, timeout=timeout_ms / 1000.0)
|
||||
elements = await coro
|
||||
payload: dict[str, Any] = {"ok": True, "elements": elements}
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
payload = {"ok": False, "error": f"ui hierarchy fetch timed out after {timeout_ms}ms"}
|
||||
except Exception as e:
|
||||
payload = {"ok": False, "error": str(e)}
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text="ui_hierarchy" if payload["ok"] else str(payload))],
|
||||
structuredContent=payload,
|
||||
isError=False,
|
||||
)
|
||||
|
||||
# --- Backend extension tools -----------------------------------------------------
|
||||
|
||||
for ext in actuator.extensions():
|
||||
mcp.add_tool(ext.handler, name=ext.name, description=ext.description)
|
||||
|
||||
logger.info(
|
||||
f"Action server '{name}' built with {len(caps)} device actions and"
|
||||
f" {len(actuator.extensions())} extension tools."
|
||||
)
|
||||
return mcp
|
||||
@@ -0,0 +1,249 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""In-process MCP session over the unified action server.
|
||||
|
||||
The in-memory transport (``create_connected_server_and_client_session``) wraps the
|
||||
server in an ``anyio`` task group whose cancel scope must be entered and exited **in
|
||||
the same task**. ``ActionSession`` therefore runs the whole context manager inside one
|
||||
owner task and communicates through events: ``call()`` and ``aclose()`` are safe from
|
||||
any task, which is what lets the session be created lazily inside an agent node and
|
||||
torn down from ``ArtemisContext.__aexit__``.
|
||||
|
||||
Transport operations additionally run **only in the owner task**, via a request
|
||||
queue: callers enqueue ``(future, name, args, timeout)`` and await the future. A
|
||||
caller that times out or is cancelled only abandons its future -- the underlying
|
||||
``call_tool`` is never cancelled mid-flight. This matters because cancelling
|
||||
``call_tool`` on the in-memory transport corrupts the shared memory streams and
|
||||
bricks the session for every subsequent caller (observed live: one 1-second
|
||||
``asyncio.wait_for`` around a slow hierarchy fetch killed a 24-minute run, with
|
||||
every later call failing on an empty-message ``BrokenResourceError``).
|
||||
|
||||
MCP's native ``read_timeout_seconds`` is just as fatal here (also observed live):
|
||||
mcp 1.29's ``_handle_response`` delivers a LATE response into the timed-out
|
||||
request's already-closed response stream, which crashes the client receive loop.
|
||||
Per-call timeouts therefore travel as a ``timeout_ms`` tool argument and fire
|
||||
inside the server tool, around the actuator call only -- a timed-out operation
|
||||
produces a normal error payload and never a late response.
|
||||
|
||||
If the transport does die despite all this, the owner loop exits, which flips
|
||||
``started`` to False so the next ``get_action_session`` builds a fresh session
|
||||
instead of leaving the rest of the run to fail on a corpse.
|
||||
|
||||
The single owner loop also serializes calls, which enforces ARTEMIS's
|
||||
single-device policy at the one choke point all agents share -- in particular it
|
||||
stops a nested Validator -> FailureAnalyzer flow from interleaving two device
|
||||
actions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
from artemis.mcp.action_types import ActionResult, ObserveResult
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["ActionSession", "get_action_session"]
|
||||
|
||||
_SHUTDOWN = object() # queue sentinel
|
||||
|
||||
|
||||
class ActionSession:
|
||||
"""Owns the in-memory MCP client/server pair for one device run."""
|
||||
|
||||
def __init__(self, server: Any):
|
||||
self._server = server
|
||||
self._session: Any = None
|
||||
self._ready = asyncio.Event()
|
||||
self._owner: asyncio.Task | None = None
|
||||
self._error: BaseException | None = None
|
||||
self._requests: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async def _run(self) -> None:
|
||||
try:
|
||||
# Enter and exit strictly within this task (anyio cancel-scope rule).
|
||||
async with create_connected_server_and_client_session(self._server) as session:
|
||||
self._session = session
|
||||
self._ready.set()
|
||||
await self._serve(session)
|
||||
except BaseException as e:
|
||||
self._error = e
|
||||
self._ready.set()
|
||||
raise
|
||||
finally:
|
||||
self._session = None
|
||||
self._fail_pending("ActionSession owner task has exited.")
|
||||
|
||||
async def _serve(self, session: Any) -> None:
|
||||
"""Executes queued transport calls until the shutdown sentinel arrives."""
|
||||
while True:
|
||||
item = await self._requests.get()
|
||||
if item is _SHUTDOWN:
|
||||
return
|
||||
fut, name, args = item
|
||||
if fut.cancelled():
|
||||
continue # caller gave up before we started; nothing was sent
|
||||
try:
|
||||
result = await session.call_tool(name, args)
|
||||
except BaseException as e:
|
||||
if not fut.cancelled():
|
||||
fut.set_exception(e)
|
||||
if isinstance(e, asyncio.CancelledError):
|
||||
raise
|
||||
if isinstance(e, (anyio.ClosedResourceError, anyio.BrokenResourceError)):
|
||||
# The transport is dead; no future call on this session can
|
||||
# succeed. Exit so `started` flips False and the next
|
||||
# get_action_session() builds a fresh session.
|
||||
logger.error(
|
||||
"Action session transport failed (%r); retiring this session.", e
|
||||
)
|
||||
return
|
||||
else:
|
||||
if not fut.cancelled():
|
||||
fut.set_result(result)
|
||||
|
||||
def _fail_pending(self, reason: str) -> None:
|
||||
while not self._requests.empty():
|
||||
item = self._requests.get_nowait()
|
||||
if item is _SHUTDOWN:
|
||||
continue
|
||||
fut = item[0]
|
||||
if not fut.done():
|
||||
fut.set_exception(RuntimeError(reason))
|
||||
|
||||
async def start(self) -> "ActionSession":
|
||||
if self._owner is not None:
|
||||
return self
|
||||
self._owner = asyncio.create_task(self._run(), name="artemis-action-session")
|
||||
await self._ready.wait()
|
||||
if self._error is not None:
|
||||
error = self._error
|
||||
self._owner = None
|
||||
self._error = None
|
||||
raise RuntimeError(f"Failed to start action session: {error}") from error
|
||||
return self
|
||||
|
||||
@property
|
||||
def started(self) -> bool:
|
||||
return self._session is not None
|
||||
|
||||
async def call_raw(self, name: str, args: dict[str, Any]) -> Any:
|
||||
"""Calls a tool and returns the raw ``CallToolResult``.
|
||||
|
||||
There is deliberately no transport-level timeout here (see the module
|
||||
docstring). Tools that support one take a ``timeout_ms`` argument and time
|
||||
out server-side. Wrapping this coroutine in ``asyncio.wait_for`` merely
|
||||
abandons the caller's future -- harmless, because the transport call itself
|
||||
runs in the owner task and cannot be cancelled from here.
|
||||
"""
|
||||
if self._session is None:
|
||||
raise RuntimeError("ActionSession is not started.")
|
||||
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||
self._requests.put_nowait((fut, name, args))
|
||||
result = await fut
|
||||
if getattr(result, "isError", False):
|
||||
# Protocol-level failure (unknown tool, schema violation) -- a programming
|
||||
# error, not a device observation.
|
||||
text = ""
|
||||
for block in getattr(result, "content", []) or []:
|
||||
if getattr(block, "type", None) == "text":
|
||||
text = block.text
|
||||
break
|
||||
raise RuntimeError(f"MCP tool '{name}' failed: {text}")
|
||||
return result
|
||||
|
||||
async def call(self, name: str, args: dict[str, Any]) -> ActionResult:
|
||||
"""Calls a device action tool and returns its typed result."""
|
||||
result = await self.call_raw(name, args)
|
||||
return ActionResult.model_validate(result.structuredContent)
|
||||
|
||||
async def observe(
|
||||
self, settle_ms: int = 400, include_image: bool = False
|
||||
) -> ObserveResult:
|
||||
"""Captures and indexes the current screen via the internal observe tool."""
|
||||
result = await self.call_raw(
|
||||
"observe_screen", {"settle_ms": settle_ms, "include_image": include_image}
|
||||
)
|
||||
return ObserveResult.model_validate(result.structuredContent)
|
||||
|
||||
async def screenshot_b64(self, timeout: float | None = None) -> str:
|
||||
"""Returns the current screen as a base64 string (no persistence)."""
|
||||
args = {"timeout_ms": int(timeout * 1000)} if timeout else {}
|
||||
result = await self.call_raw("take_screenshot", args)
|
||||
payload = result.structuredContent or {}
|
||||
# FastMCP wraps plain-dict returns as {"result": ...} only for non-dict types;
|
||||
# dicts pass through.
|
||||
data = payload.get("result", payload)
|
||||
if not data.get("ok"):
|
||||
raise RuntimeError(f"take_screenshot failed: {data.get('error')}")
|
||||
return data["image_b64"]
|
||||
|
||||
async def ui_hierarchy(self, timeout: float | None = None) -> Any:
|
||||
"""Returns the raw UI element hierarchy."""
|
||||
args = {"timeout_ms": int(timeout * 1000)} if timeout else {}
|
||||
result = await self.call_raw("get_ui_hierarchy", args)
|
||||
payload = result.structuredContent or {}
|
||||
data = payload.get("result", payload)
|
||||
if not data.get("ok"):
|
||||
raise RuntimeError(f"get_ui_hierarchy failed: {data.get('error')}")
|
||||
return data["elements"]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Shuts the session down; safe to call from any task."""
|
||||
if self._owner is None:
|
||||
return
|
||||
self._requests.put_nowait(_SHUTDOWN)
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(self._owner), timeout=5.0)
|
||||
except (TimeoutError, asyncio.CancelledError, Exception) as e:
|
||||
logger.warning(f"Action session owner task did not exit cleanly: {e!r}")
|
||||
finally:
|
||||
self._owner = None
|
||||
self._ready = asyncio.Event()
|
||||
|
||||
|
||||
async def get_action_session(ctx: Any, actuator: Any = None) -> ActionSession:
|
||||
"""Returns the context's action session, creating and starting it on first use.
|
||||
|
||||
Contract validation happens here (inside ``build_action_server``), so a backend
|
||||
that cannot satisfy the manifest fails before the first device interaction.
|
||||
"""
|
||||
existing = getattr(ctx, "action_session", None)
|
||||
if existing is not None and existing.started:
|
||||
return existing
|
||||
if existing is not None:
|
||||
# A dead session still owns a task and memory streams: release them
|
||||
# before the replacement is built.
|
||||
try:
|
||||
await existing.aclose()
|
||||
except Exception:
|
||||
logger.debug("Closing a dead action session failed; replacing it anyway.")
|
||||
|
||||
from artemis.mcp.action_server import build_action_server
|
||||
from artemis.mcp.actuators.adb import AdbActuator
|
||||
|
||||
actuator = actuator or getattr(ctx, "actuator", None) or AdbActuator(ctx)
|
||||
server = build_action_server(actuator)
|
||||
session = ActionSession(server)
|
||||
await session.start()
|
||||
try:
|
||||
ctx.action_session = session
|
||||
except Exception:
|
||||
# Contexts that forbid new attributes (strict mocks) can still use the session.
|
||||
logger.debug("Could not store action session on ctx; proceeding unattached.")
|
||||
return session
|
||||
@@ -0,0 +1,86 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Typed results for the device action layer.
|
||||
|
||||
These models replace the ``"Error" in text`` substring sniffing: success is carried by
|
||||
``ok``/``code``, while ``message`` remains the human/LLM-facing summary and keeps its
|
||||
historical wording. Kept dependency-free (pydantic only) so servers, adapters, and
|
||||
tests can all import it cheaply.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
__all__ = ["ActionCode", "ActionResult", "ObserveResult"]
|
||||
|
||||
|
||||
class ActionCode(str, Enum):
|
||||
"""Machine-readable outcome classification for a device action."""
|
||||
|
||||
OK = "OK"
|
||||
INVALID_ARGS = "INVALID_ARGS"
|
||||
TARGET_NOT_FOUND = "TARGET_NOT_FOUND"
|
||||
DEVICE_ERROR = "DEVICE_ERROR"
|
||||
PACKAGE_NOT_FOUND = "PACKAGE_NOT_FOUND"
|
||||
TIMEOUT = "TIMEOUT"
|
||||
UNSUPPORTED = "UNSUPPORTED"
|
||||
|
||||
|
||||
class ActionResult(BaseModel):
|
||||
"""Outcome of one device action, independent of any transport or agent."""
|
||||
|
||||
ok: bool
|
||||
code: ActionCode
|
||||
action: str
|
||||
message: str
|
||||
"""Agent-facing summary; wording matches the historical executor outcomes."""
|
||||
detail: str | None = None
|
||||
"""Raw driver/controller error for diagnostics; never shown to the LLM."""
|
||||
normalized_coordinates: list[int] | None = None
|
||||
duration_ms: int | None = None
|
||||
|
||||
@classmethod
|
||||
def success(cls, action: str, message: str, **kwargs: Any) -> "ActionResult":
|
||||
return cls(ok=True, code=ActionCode.OK, action=action, message=message, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def failure(
|
||||
cls,
|
||||
action: str,
|
||||
message: str,
|
||||
code: ActionCode = ActionCode.DEVICE_ERROR,
|
||||
**kwargs: Any,
|
||||
) -> "ActionResult":
|
||||
return cls(ok=False, code=code, action=action, message=message, **kwargs)
|
||||
|
||||
|
||||
class ObserveResult(BaseModel):
|
||||
"""Post-action observation: persisted screenshot plus the parsed element list."""
|
||||
|
||||
ok: bool
|
||||
code: ActionCode = ActionCode.OK
|
||||
message: str = ""
|
||||
screenshot_path: str | None = None
|
||||
image_name: str | None = None
|
||||
"""Stem of ``screenshot_path``; stable key used by trace/replay consumers."""
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
elements_text: str | None = None
|
||||
elements: list[dict[str, Any]] = Field(default_factory=list)
|
||||
hierarchy_ok: bool = True
|
||||
"""False when the screenshot succeeded but the UI hierarchy parse failed; callers
|
||||
must then keep their previous element index rather than clobbering it."""
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Actuator backends: pluggable implementations of the device action contract."""
|
||||
|
||||
from artemis.mcp.actuators.base import Actuator
|
||||
from artemis.mcp.actuators.adb import AdbActuator
|
||||
from artemis.mcp.actuators.mock import MockActuator
|
||||
|
||||
__all__ = ["Actuator", "AdbActuator", "MockActuator"]
|
||||
@@ -0,0 +1,419 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""ADB-backed actuator: the reference implementation of the actuator contract.
|
||||
|
||||
The device-call bodies here were extracted from
|
||||
``artemis.agents.validator.tool_declarations.MobileActionExecutor``; the outcome
|
||||
message wording is preserved byte-for-byte because agent transcripts, trace payloads,
|
||||
and existing tests assert on it.
|
||||
|
||||
Error convention: device-level refusals the controller *reports* (an ``error`` field,
|
||||
a falsy success flag) come back as ``ActionResult(ok=False)`` with the historical
|
||||
message. Unexpected exceptions are allowed to propagate -- the caller (executor or MCP
|
||||
server layer) owns the ``"Error during X: {e}"`` wrapping, exactly as the original
|
||||
executor's ``try/except`` did.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.mcp.action_manifest import DEVICE_ACTIONS, ExtensionTool
|
||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["AdbActuator", "find_element_at_coords", "ensure_focus_at_coords"]
|
||||
|
||||
|
||||
def find_element_at_coords(elements: list[dict], x: int, y: int) -> dict | None:
|
||||
"""Finds the smallest (leaf-most) focusable element containing pixel [x, y].
|
||||
|
||||
Moved from ``artemis.mcp.adb_server`` so both the stdio server and in-process
|
||||
actuator share one implementation.
|
||||
"""
|
||||
matching_element = None
|
||||
min_area = float("inf")
|
||||
|
||||
for elem in elements:
|
||||
is_focusable = (
|
||||
elem.get("focusable") == "true"
|
||||
or elem.get("clickable") == "true"
|
||||
or "EditText" in str(elem.get("class", ""))
|
||||
)
|
||||
if not is_focusable:
|
||||
continue
|
||||
|
||||
bounds_str = elem.get("bounds")
|
||||
if bounds_str and isinstance(bounds_str, str):
|
||||
match = re.match(r"\[(\-?\d+),(\-?\d+)\]\[(\-?\d+),(\-?\d+)\]", bounds_str)
|
||||
if match:
|
||||
x1, y1, x2, y2 = map(int, match.groups())
|
||||
if x1 <= x <= x2 and y1 <= y <= y2:
|
||||
area = (x2 - x1) * (y2 - y1)
|
||||
if area < min_area:
|
||||
min_area = area
|
||||
matching_element = elem
|
||||
|
||||
return matching_element
|
||||
|
||||
|
||||
async def ensure_focus_at_coords(controller, x: int, y: int) -> str | None:
|
||||
"""Ensures the element at pixel [x, y] is focused, tapping only if needed.
|
||||
|
||||
Returns an error string on failure, ``None`` on success. Moved from
|
||||
``artemis.mcp.adb_server``.
|
||||
"""
|
||||
try:
|
||||
elements = await controller.get_ui_elements()
|
||||
elem = find_element_at_coords(elements, x, y)
|
||||
if elem and elem.get("focused") == "true":
|
||||
logger.info(f"Element under [{x}, {y}] is already focused. Skipping tap.")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check focus status: {e}. Falling back to unconditional tap.")
|
||||
|
||||
result = await controller.tap_at(x=x, y=y)
|
||||
if hasattr(result, "error") and result.error:
|
||||
return result.error
|
||||
|
||||
# Wait for the UI to settle and keyboard to pop up after tapping
|
||||
await asyncio.sleep(1.0)
|
||||
return None
|
||||
|
||||
|
||||
class AdbActuator:
|
||||
"""Drives an Android device through ``UnifiedMobileController`` over ADB."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: ArtemisContext,
|
||||
controller: UnifiedMobileController | None = None,
|
||||
):
|
||||
self.ctx = ctx
|
||||
self.controller = controller or UnifiedMobileController(ctx)
|
||||
|
||||
# --- Contract --------------------------------------------------------------------
|
||||
|
||||
def capabilities(self) -> frozenset[str]:
|
||||
return DEVICE_ACTIONS
|
||||
|
||||
def extensions(self) -> list[ExtensionTool]:
|
||||
return []
|
||||
|
||||
# --- Coordinate helpers ----------------------------------------------------------
|
||||
|
||||
def _dims(self) -> tuple[int, int]:
|
||||
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
|
||||
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
|
||||
return width or 1080, height or 2400
|
||||
|
||||
def _to_px(self, nx: int, ny: int) -> tuple[int, int]:
|
||||
width, height = self._dims()
|
||||
x = int(max(0, min(width - 1, int(nx) * width / 1000)))
|
||||
y = int(max(0, min(height - 1, int(ny) * height / 1000)))
|
||||
return x, y
|
||||
|
||||
# --- Required action -------------------------------------------------------------
|
||||
|
||||
async def click_sequence(
|
||||
self, points: list[tuple[int, int]], delay_ms: int = 50
|
||||
) -> ActionResult:
|
||||
outcomes = []
|
||||
for i, (nx, ny) in enumerate(points):
|
||||
x, y = self._to_px(nx, ny)
|
||||
result = await self.controller.tap_at(x, y)
|
||||
err = getattr(result, "error", None) if result else None
|
||||
if err:
|
||||
return ActionResult.failure(
|
||||
"click_sequence",
|
||||
f"Error executing click at step {i + 1}: {err}",
|
||||
detail=str(err),
|
||||
)
|
||||
outcomes.append(f"Tapped at [{x}, {y}]")
|
||||
if i < len(points) - 1:
|
||||
await asyncio.sleep(max(0, delay_ms) / 1000.0)
|
||||
|
||||
return ActionResult.success(
|
||||
"click_sequence",
|
||||
f"Sequence clicked successfully: {'; '.join(outcomes)}",
|
||||
)
|
||||
|
||||
# --- Optional actions ------------------------------------------------------------
|
||||
|
||||
async def click(
|
||||
self, nx: int, ny: int, times: int = 1, delay_ms: int = 100
|
||||
) -> ActionResult:
|
||||
x, y = self._to_px(nx, ny)
|
||||
result = await self.controller.tap_at(x, y, times=times, delay_ms=delay_ms)
|
||||
err = getattr(result, "error", None) if result else None
|
||||
if err is not None:
|
||||
return ActionResult.failure(
|
||||
"click", f"Error executing click: {err}", detail=str(err)
|
||||
)
|
||||
return ActionResult.success(
|
||||
"click",
|
||||
f"Clicked at [{nx}, {ny}] (normalized) successfully.",
|
||||
normalized_coordinates=[int(nx), int(ny)],
|
||||
)
|
||||
|
||||
async def long_press(self, nx: int, ny: int, duration_ms: int = 1000) -> ActionResult:
|
||||
x, y = self._to_px(nx, ny)
|
||||
result = await self.controller.tap_at(
|
||||
x, y, long_press=True, long_press_duration=duration_ms
|
||||
)
|
||||
err = getattr(result, "error", None) if result else None
|
||||
if err is not None:
|
||||
return ActionResult.failure(
|
||||
"long_press", f"Error executing long press: {err}", detail=str(err)
|
||||
)
|
||||
return ActionResult.success(
|
||||
"long_press",
|
||||
f"Long pressed at [{nx}, {ny}] (normalized) for {duration_ms}ms successfully.",
|
||||
normalized_coordinates=[int(nx), int(ny)],
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
async def input_text(
|
||||
self,
|
||||
text: str,
|
||||
target: tuple[int, int] | None = None,
|
||||
clear_exist: bool = True,
|
||||
) -> ActionResult:
|
||||
coords = None
|
||||
if target:
|
||||
nx, ny = target
|
||||
x, y = self._to_px(nx, ny)
|
||||
coords = [int(nx), int(ny)]
|
||||
err = await ensure_focus_at_coords(self.controller, x, y)
|
||||
if err:
|
||||
return ActionResult.failure(
|
||||
"input_text",
|
||||
f"Error focusing element: {err}",
|
||||
detail=str(err),
|
||||
normalized_coordinates=coords,
|
||||
)
|
||||
if clear_exist:
|
||||
if hasattr(self.controller, "erase_text") and not await self.controller.erase_text():
|
||||
return ActionResult.failure(
|
||||
"input_text",
|
||||
"Failed to clear existing text",
|
||||
normalized_coordinates=coords,
|
||||
)
|
||||
elif hasattr(self.controller, "press_key"):
|
||||
# KEYCODE_MOVE_END: move cursor to the end for reliable append.
|
||||
await self.controller.press_key("123")
|
||||
if hasattr(self.controller, "type_text") and not await self.controller.type_text(
|
||||
text, clear_existing=False
|
||||
):
|
||||
return ActionResult.failure(
|
||||
"input_text",
|
||||
f"Failed typing '{text}'.",
|
||||
normalized_coordinates=coords,
|
||||
)
|
||||
|
||||
return ActionResult.success(
|
||||
"input_text",
|
||||
f"Executed typing '{text}'.",
|
||||
normalized_coordinates=coords,
|
||||
)
|
||||
|
||||
async def swipe(
|
||||
self,
|
||||
start: tuple[int, int],
|
||||
end: tuple[int, int],
|
||||
duration_ms: int = 800,
|
||||
) -> ActionResult:
|
||||
nx1, ny1 = start
|
||||
nx2, ny2 = end
|
||||
px1, py1 = self._to_px(nx1, ny1)
|
||||
px2, py2 = self._to_px(nx2, ny2)
|
||||
err = await self.controller.swipe_coords(px1, py1, px2, py2, duration_ms)
|
||||
if err:
|
||||
return ActionResult.failure("swipe", f"Error dragging: {err}", detail=str(err))
|
||||
return ActionResult.success(
|
||||
"swipe",
|
||||
f"Swipe completed successfully. Swiped from [{nx1}, {ny1}] to [{nx2}, {ny2}].",
|
||||
normalized_coordinates=[int(nx1), int(ny1), int(nx2), int(ny2)],
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
async def press_key(self, key: str) -> ActionResult:
|
||||
key_str = str(key).lower()
|
||||
if not key_str:
|
||||
return ActionResult.failure(
|
||||
"press_key",
|
||||
f"Error executing key press '{key}'.",
|
||||
code=ActionCode.INVALID_ARGS,
|
||||
)
|
||||
|
||||
if key_str == "back" and hasattr(self.controller, "go_back"):
|
||||
await self.controller.go_back()
|
||||
elif key_str == "home" and hasattr(self.controller, "go_home"):
|
||||
await self.controller.go_home()
|
||||
elif key_str == "enter" and hasattr(self.controller, "press_enter"):
|
||||
await self.controller.press_enter()
|
||||
elif hasattr(self.controller, "press_key"):
|
||||
# Pass unrecognized keycodes through verbatim (KEYCODE_* names, numeric
|
||||
# codes): the driver resolves or forwards them, matching the historical
|
||||
# adb_server behavior of accepting any Android key event.
|
||||
res = await self.controller.press_key(key)
|
||||
err = getattr(res, "error", None) if res else None
|
||||
if err:
|
||||
return ActionResult.failure(
|
||||
"press_key",
|
||||
f"Error executing key press '{key}': {err}",
|
||||
detail=str(err),
|
||||
)
|
||||
if not res:
|
||||
return ActionResult.failure(
|
||||
"press_key", f"Error executing key press '{key}'."
|
||||
)
|
||||
|
||||
return ActionResult.success("press_key", f"Executed key press '{key}'.")
|
||||
|
||||
async def manage_app(self, action: str, app_name: str) -> ActionResult:
|
||||
# Imported lazily: launch_app pulls in tool wrappers that are costly at import.
|
||||
from artemis.tools.mobile.launch_app import find_package
|
||||
from artemis.utils.app_launch_utils import launch_app_with_retries
|
||||
|
||||
res = find_package(self.ctx, app_name, use_fallback=False)
|
||||
pkg = await res if inspect.iscoroutine(res) else res
|
||||
if not pkg:
|
||||
return ActionResult.failure(
|
||||
"manage_app",
|
||||
f"Error finding package for app: {app_name}",
|
||||
code=ActionCode.PACKAGE_NOT_FOUND,
|
||||
)
|
||||
target_pkg = pkg
|
||||
|
||||
if action.lower() == "launch":
|
||||
res_launch = launch_app_with_retries(self.ctx, target_pkg)
|
||||
if inspect.iscoroutine(res_launch):
|
||||
res_launch = await res_launch
|
||||
if isinstance(res_launch, tuple):
|
||||
success, error_msg = res_launch
|
||||
else:
|
||||
success, error_msg = bool(res_launch), ""
|
||||
|
||||
if success:
|
||||
return ActionResult.success(
|
||||
"manage_app",
|
||||
f"Launched app '{app_name}' ({target_pkg}) successfully.",
|
||||
)
|
||||
return ActionResult.failure(
|
||||
"manage_app",
|
||||
f"Failed to launch app '{app_name}': {error_msg}",
|
||||
detail=error_msg or None,
|
||||
)
|
||||
elif action.lower() == "stop":
|
||||
if hasattr(self.controller, "terminate_app"):
|
||||
res_term = self.controller.terminate_app(target_pkg)
|
||||
if inspect.iscoroutine(res_term):
|
||||
await res_term
|
||||
return ActionResult.success(
|
||||
"manage_app", f"Terminated app '{app_name}' successfully."
|
||||
)
|
||||
return ActionResult.failure(
|
||||
"manage_app",
|
||||
f"Invalid manage_app action: {action}",
|
||||
code=ActionCode.INVALID_ARGS,
|
||||
)
|
||||
|
||||
async def wait_for_delay(self, time_in_ms: int) -> ActionResult:
|
||||
delay_s = max(0.0, float(time_in_ms) / 1000.0)
|
||||
await asyncio.sleep(delay_s)
|
||||
return ActionResult.success(
|
||||
"wait_for_delay",
|
||||
f"Waited for {time_in_ms}ms successfully.",
|
||||
duration_ms=int(time_in_ms),
|
||||
)
|
||||
|
||||
async def wait_for_text(
|
||||
self, text: str, wait_state: str | None = None, timeout_ms: int | None = None
|
||||
) -> ActionResult:
|
||||
timeout_s = (timeout_ms or 5000) / 1000.0
|
||||
start = time.time()
|
||||
found = False
|
||||
target_state = (wait_state or "appear").lower()
|
||||
|
||||
while time.time() - start < timeout_s:
|
||||
tree = await self.controller.get_ui_elements()
|
||||
text_present = text.lower() in str(tree or "").lower()
|
||||
if target_state == "appear" and text_present:
|
||||
found = True
|
||||
break
|
||||
elif target_state == "disappear" and not text_present:
|
||||
found = True
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if found:
|
||||
return ActionResult.success(
|
||||
"wait_for_text",
|
||||
f"Successfully waited for text '{text}' to {target_state}.",
|
||||
)
|
||||
return ActionResult.failure(
|
||||
"wait_for_text",
|
||||
f"Timed out waiting for text '{text}' to {target_state}.",
|
||||
code=ActionCode.TIMEOUT,
|
||||
)
|
||||
|
||||
async def open_link(self, url: str) -> ActionResult:
|
||||
success = await self.controller.open_url(url)
|
||||
if success:
|
||||
return ActionResult.success("open_link", f"Opened link '{url}' successfully.")
|
||||
return ActionResult.failure("open_link", f"Failed to open link '{url}'.")
|
||||
|
||||
async def erase_one_char(self) -> ActionResult:
|
||||
success = await self.controller.erase_text(nb_chars=1)
|
||||
if success:
|
||||
return ActionResult.success("erase_one_char", "Erased one character.")
|
||||
return ActionResult.failure("erase_one_char", "Failed to erase one character.")
|
||||
|
||||
async def focus_and_clear_text(self, nx: int, ny: int) -> ActionResult:
|
||||
x, y = self._to_px(nx, ny)
|
||||
err = await ensure_focus_at_coords(self.controller, x, y)
|
||||
if err:
|
||||
return ActionResult.failure(
|
||||
"focus_and_clear_text",
|
||||
f"Error focusing element: {err}",
|
||||
detail=str(err),
|
||||
normalized_coordinates=[int(nx), int(ny)],
|
||||
)
|
||||
success = await self.controller.erase_text()
|
||||
if success:
|
||||
return ActionResult.success(
|
||||
"focus_and_clear_text",
|
||||
f"Cleared text at [{nx}, {ny}] (normalized) successfully.",
|
||||
normalized_coordinates=[int(nx), int(ny)],
|
||||
)
|
||||
return ActionResult.failure("focus_and_clear_text", "Failed to erase text.")
|
||||
|
||||
# --- Internal observation primitives ---------------------------------------------
|
||||
|
||||
async def take_screenshot(self) -> str:
|
||||
return await self.controller.take_screenshot()
|
||||
|
||||
async def get_ui_elements(self) -> Any:
|
||||
return await self.controller.get_ui_elements()
|
||||
|
||||
async def get_screen_data(self) -> Any:
|
||||
return await self.controller.get_screen_data()
|
||||
@@ -0,0 +1,113 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""The actuator protocol: what any backend must look like to drive a device.
|
||||
|
||||
An actuator answers exactly one question -- *how to perform a physical action* -- in
|
||||
the canonical 0-1000 normalized coordinate space. It knows nothing about LangGraph
|
||||
``State``, prompts, agents, screenshots-as-observations, or MCP. Pixel geometry (or a
|
||||
robot arm's motion planning) is a private detail behind this boundary.
|
||||
|
||||
A backend advertises what it can do through :meth:`Actuator.capabilities` (names from
|
||||
``artemis.mcp.action_manifest.DEVICE_ACTIONS``) and may contribute tools nobody
|
||||
anticipated through :meth:`Actuator.extensions`. ``validate_actuator`` in the manifest
|
||||
enforces the contract at session start.
|
||||
|
||||
Methods for actions a backend does not implement should simply not exist (or raise
|
||||
``NotImplementedError``); callers consult ``capabilities()`` first.
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from artemis.mcp.action_manifest import ExtensionTool
|
||||
from artemis.mcp.action_types import ActionResult
|
||||
|
||||
__all__ = ["Actuator"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Actuator(Protocol):
|
||||
"""Protocol every actuator backend implements.
|
||||
|
||||
All coordinates are 0-1000 normalized. 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.
|
||||
"""
|
||||
|
||||
def capabilities(self) -> frozenset[str]:
|
||||
"""Names from ``DEVICE_ACTIONS`` this backend implements."""
|
||||
...
|
||||
|
||||
def extensions(self) -> list[ExtensionTool]:
|
||||
"""Backend-specific tools unknown to the manifest."""
|
||||
...
|
||||
|
||||
# --- Required action -------------------------------------------------------------
|
||||
|
||||
async def click_sequence(
|
||||
self, points: list[tuple[int, int]], delay_ms: int = 50
|
||||
) -> ActionResult: ...
|
||||
|
||||
# --- Optional actions ------------------------------------------------------------
|
||||
|
||||
async def click(
|
||||
self, nx: int, ny: int, times: int = 1, delay_ms: int = 100
|
||||
) -> ActionResult: ...
|
||||
|
||||
async def long_press(self, nx: int, ny: int, duration_ms: int = 1000) -> ActionResult: ...
|
||||
|
||||
async def input_text(
|
||||
self,
|
||||
text: str,
|
||||
target: tuple[int, int] | None = None,
|
||||
clear_exist: bool = True,
|
||||
) -> ActionResult: ...
|
||||
|
||||
async def swipe(
|
||||
self,
|
||||
start: tuple[int, int],
|
||||
end: tuple[int, int],
|
||||
duration_ms: int = 800,
|
||||
) -> ActionResult: ...
|
||||
|
||||
async def press_key(self, key: str) -> ActionResult: ...
|
||||
|
||||
async def manage_app(self, action: str, app_name: str) -> ActionResult: ...
|
||||
|
||||
async def wait_for_delay(self, time_in_ms: int) -> ActionResult: ...
|
||||
|
||||
async def wait_for_text(
|
||||
self, text: str, wait_state: str | None = None, timeout_ms: int | None = None
|
||||
) -> ActionResult: ...
|
||||
|
||||
async def open_link(self, url: str) -> ActionResult: ...
|
||||
|
||||
async def erase_one_char(self) -> ActionResult: ...
|
||||
|
||||
async def focus_and_clear_text(self, nx: int, ny: int) -> ActionResult: ...
|
||||
|
||||
# --- Internal observation primitives ---------------------------------------------
|
||||
|
||||
async def take_screenshot(self) -> str:
|
||||
"""Returns the current screen as a base64-encoded JPEG/PNG string."""
|
||||
...
|
||||
|
||||
async def get_ui_elements(self) -> Any:
|
||||
"""Returns the raw UI hierarchy elements as provided by the device."""
|
||||
...
|
||||
|
||||
async def get_screen_data(self) -> Any:
|
||||
"""Returns combined screen data (screenshot + elements + dimensions)."""
|
||||
...
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Mock actuator for tests: real actuator code paths over an in-memory device.
|
||||
|
||||
Subclasses :class:`AdbActuator` deliberately, so tests exercise the same coordinate
|
||||
conversion and controller dispatch as production -- only the driver is in-memory.
|
||||
``capabilities``/``extensions`` are overridable to model partial and extended
|
||||
backends.
|
||||
"""
|
||||
|
||||
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
||||
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||
from artemis.mcp.action_manifest import DEVICE_ACTIONS, ExtensionTool
|
||||
from artemis.mcp.actuators.adb import AdbActuator
|
||||
from artemis.platform import platform
|
||||
|
||||
__all__ = ["MockActuator"]
|
||||
|
||||
|
||||
class MockActuator(AdbActuator):
|
||||
"""An :class:`AdbActuator` bound to a :class:`MockDeviceDriver`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 1080,
|
||||
height: int = 2400,
|
||||
capabilities: frozenset[str] | None = None,
|
||||
extensions: list[ExtensionTool] | None = None,
|
||||
):
|
||||
driver = MockDeviceDriver(width=width, height=height)
|
||||
ctx = ArtemisContext(
|
||||
trace_id="mock-actuator",
|
||||
device=DeviceContext(
|
||||
host_platform=platform.os_type.name,
|
||||
mobile_platform=DevicePlatform.ANDROID,
|
||||
device_id=driver.device_id,
|
||||
device_width=width,
|
||||
device_height=height,
|
||||
),
|
||||
)
|
||||
# get_driver(ctx) returns a cached `_active_driver` when present
|
||||
# (artemis/drivers/factory.py), the established test seam.
|
||||
ctx._active_driver = driver
|
||||
super().__init__(ctx)
|
||||
self.driver = driver
|
||||
self._capabilities = capabilities if capabilities is not None else DEVICE_ACTIONS
|
||||
self._extensions = list(extensions or [])
|
||||
|
||||
def capabilities(self) -> frozenset[str]:
|
||||
return self._capabilities
|
||||
|
||||
def extensions(self) -> list[ExtensionTool]:
|
||||
return self._extensions
|
||||
|
||||
@property
|
||||
def action_history(self) -> list[dict]:
|
||||
"""The mock driver's recorded actions, for assertions."""
|
||||
return self.driver.action_history
|
||||
+46
-72
@@ -17,7 +17,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
@@ -43,33 +42,46 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.platform import platform
|
||||
from artemis.utils.app_launch_utils import launch_app_with_retries
|
||||
|
||||
# Redirect stdio to prevent logs from breaking MCP JSON-RPC protocol and causing deadlocks.
|
||||
try:
|
||||
from artemis.config import settings
|
||||
|
||||
log_path = Path(settings.TRACES_PATH) / "mcp_server.log"
|
||||
except Exception:
|
||||
log_path = Path("traces") / "mcp_server.log"
|
||||
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for name in ["artemis", __name__]:
|
||||
log_instance = logging.getLogger(name)
|
||||
log_instance.setLevel(logging.DEBUG)
|
||||
log_instance.propagate = False
|
||||
# Remove standard streams handlers
|
||||
log_instance.handlers = [
|
||||
h for h in log_instance.handlers if not isinstance(h, logging.StreamHandler)
|
||||
]
|
||||
fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s"))
|
||||
log_instance.addHandler(fh)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("MCP Server Python process fully started. Configuring FastMCP service...")
|
||||
|
||||
# Avoid concurrent SQLite WAL locks by blocking child processes from connecting to primary DataEngine.
|
||||
os.environ["ARTEMIS_IPC_PORT"] = ""
|
||||
|
||||
def configure_stdio_mode() -> None:
|
||||
"""Applies process-wide settings required when serving MCP over stdio.
|
||||
|
||||
These are destructive to the host process (console logging is removed and the
|
||||
DataEngine IPC port is cleared), so they must only run when this module owns the
|
||||
process -- i.e. from the ``__main__`` block. Importing this module for its tools
|
||||
or for :func:`_get_controller` must leave the caller's logging and environment
|
||||
untouched.
|
||||
"""
|
||||
# Redirect stdio to prevent logs from breaking MCP JSON-RPC protocol and causing deadlocks.
|
||||
try:
|
||||
from artemis.config import settings
|
||||
|
||||
log_path = Path(settings.TRACES_PATH) / "mcp_server.log"
|
||||
except Exception:
|
||||
log_path = Path("traces") / "mcp_server.log"
|
||||
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for name in ["artemis", __name__]:
|
||||
log_instance = logging.getLogger(name)
|
||||
log_instance.setLevel(logging.DEBUG)
|
||||
log_instance.propagate = False
|
||||
# Remove standard streams handlers
|
||||
log_instance.handlers = [
|
||||
h for h in log_instance.handlers if not isinstance(h, logging.StreamHandler)
|
||||
]
|
||||
fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s"))
|
||||
log_instance.addHandler(fh)
|
||||
|
||||
logger.info("MCP Server Python process fully started. Configuring FastMCP service...")
|
||||
|
||||
# Avoid concurrent SQLite WAL locks by blocking child processes from connecting
|
||||
# to the primary DataEngine.
|
||||
os.environ["ARTEMIS_IPC_PORT"] = ""
|
||||
|
||||
|
||||
# Create minimal MCP server
|
||||
mcp = FastMCP("Android_ADB_Controller")
|
||||
@@ -161,52 +173,13 @@ def _get_controller(device_serial: str | None = None):
|
||||
return controller
|
||||
|
||||
|
||||
def _find_element_at_coords(elements: list[dict], x: int, y: int) -> dict | None:
|
||||
"""Finds the smallest (leaf-most) focusable element containing [x, y]."""
|
||||
matching_element = None
|
||||
min_area = float("inf")
|
||||
|
||||
for elem in elements:
|
||||
is_focusable = (
|
||||
elem.get("focusable") == "true"
|
||||
or elem.get("clickable") == "true"
|
||||
or "EditText" in str(elem.get("class", ""))
|
||||
)
|
||||
if not is_focusable:
|
||||
continue
|
||||
|
||||
bounds_str = elem.get("bounds")
|
||||
if bounds_str and isinstance(bounds_str, str):
|
||||
match = re.match(r"\[(\-?\d+),(\-?\d+)\]\[(\-?\d+),(\-?\d+)\]", bounds_str)
|
||||
if match:
|
||||
x1, y1, x2, y2 = map(int, match.groups())
|
||||
if x1 <= x <= x2 and y1 <= y <= y2:
|
||||
area = (x2 - x1) * (y2 - y1)
|
||||
if area < min_area:
|
||||
min_area = area
|
||||
matching_element = elem
|
||||
|
||||
return matching_element
|
||||
|
||||
|
||||
async def _ensure_focus_at_coords(controller, x: int, y: int) -> str | None:
|
||||
"""Ensures the element at [x, y] is focused, tapping only if it is not already focused."""
|
||||
try:
|
||||
elements = await controller.get_ui_elements()
|
||||
elem = _find_element_at_coords(elements, x, y)
|
||||
if elem and elem.get("focused") == "true":
|
||||
logger.info(f"Element under [{x}, {y}] is already focused. Skipping tap.")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check focus status: {e}. Falling back to unconditional tap.")
|
||||
|
||||
result = await controller.tap_at(x=x, y=y)
|
||||
if hasattr(result, "error") and result.error:
|
||||
return result.error
|
||||
|
||||
# Wait for the UI to settle and keyboard to pop up after tapping
|
||||
await asyncio.sleep(1.0)
|
||||
return None
|
||||
# Shared with the in-process actuator layer; this stdio server keeps its legacy
|
||||
# pixel-space contract for external MCP clients, but the element/focus logic has a
|
||||
# single implementation in artemis.mcp.actuators.adb.
|
||||
from artemis.mcp.actuators.adb import ( # noqa: E402 pylint: disable=wrong-import-position
|
||||
ensure_focus_at_coords as _ensure_focus_at_coords,
|
||||
find_element_at_coords as _find_element_at_coords,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -480,6 +453,7 @@ async def get_ui_hierarchy(ctx: Context) -> str:
|
||||
if __name__ == "__main__":
|
||||
from artemis.runtime import shutdown_awake_service, start_awake_service
|
||||
|
||||
configure_stdio_mode()
|
||||
start_awake_service()
|
||||
try:
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Screen observation: capture, fuse, index, and persist -- without LangGraph State.
|
||||
|
||||
This is the act-then-observe half that used to be welded into every executor action
|
||||
(``capture_screenshot_and_parse_ui``). Split out so that actuation and observation are
|
||||
separate calls: the Flash/FailureAnalyzer adapter observes after each action, while the
|
||||
Validator -- which runs its own UI-change polling -- simply never calls it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
|
||||
from artemis.config import get_temp_dir
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.mcp.action_types import ActionCode, ObserveResult
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml
|
||||
from artemis.utils.visualization import format_minimal_list_with_elements
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["observe"]
|
||||
|
||||
|
||||
async def observe(
|
||||
ctx: ArtemisContext,
|
||||
controller: UnifiedMobileController,
|
||||
settle_ms: int = 400,
|
||||
ocr_results: list | None = None,
|
||||
) -> tuple[ObserveResult, bytes | None]:
|
||||
"""Captures and indexes the current screen.
|
||||
|
||||
Returns the structured observation plus the raw screenshot bytes (an in-process
|
||||
convenience; the serialized ``ObserveResult`` carries only the persisted path).
|
||||
|
||||
Mirrors the historical ``capture_screenshot_and_parse_ui`` behavior exactly:
|
||||
a settling delay, screen capture, XML fusion and element indexing, persistence via
|
||||
the data engine with a temp-file fallback -- and the same failure semantics: a
|
||||
failed capture yields ``ok=False``; a failed hierarchy parse still returns the
|
||||
screenshot with ``hierarchy_ok=False``.
|
||||
"""
|
||||
try:
|
||||
if settle_ms > 0:
|
||||
logger.info(f"Delaying {settle_ms / 1000:.1f}s before capturing screen state...")
|
||||
await asyncio.sleep(settle_ms / 1000.0)
|
||||
else:
|
||||
logger.info("Retrieving screen data directly (skip settling)...")
|
||||
device_data = await controller.get_screen_data()
|
||||
screenshot_bytes = base64.b64decode(device_data.base64)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to capture screen state: {e}")
|
||||
return (
|
||||
ObserveResult(
|
||||
ok=False,
|
||||
code=ActionCode.DEVICE_ERROR,
|
||||
message=f"Failed to capture screen state: {e}",
|
||||
hierarchy_ok=False,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
xml_hierarchy = None
|
||||
ocr_results = ocr_results or []
|
||||
elements: list[dict] = []
|
||||
hierarchy_ok = True
|
||||
width = height = None
|
||||
try:
|
||||
xml_hierarchy = device_data.elements
|
||||
width = device_data.width or 1080
|
||||
height = device_data.height or 2400
|
||||
if ctx.device:
|
||||
ctx.device.device_width = width
|
||||
ctx.device.device_height = height
|
||||
|
||||
fused_xml = fuse_ocr_with_xml(xml_hierarchy, ocr_results)
|
||||
minimal_list_str, elements, _ = format_minimal_list_with_elements(fused_xml, width, height)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch or fuse UI hierarchy: {e}")
|
||||
minimal_list_str = "Not available due to hierarchy error."
|
||||
hierarchy_ok = False
|
||||
|
||||
screenshot_path = None
|
||||
if ctx.data_engine:
|
||||
try:
|
||||
image_name = ctx.data_engine.get_or_create_image(
|
||||
screenshot_bytes,
|
||||
ui_tree=xml_hierarchy,
|
||||
ocr_result=ocr_results,
|
||||
)
|
||||
screenshot_path = str(ctx.data_engine.get_image_path(image_name))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save image in DataEngine: {e}")
|
||||
|
||||
if not screenshot_path:
|
||||
try:
|
||||
temp_dir = get_temp_dir("screenshots")
|
||||
temp_file = temp_dir / f"screenshot_{int(time.time())}.jpg"
|
||||
temp_file.write_bytes(screenshot_bytes)
|
||||
screenshot_path = str(temp_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save fallback screenshot: {e}")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
return (
|
||||
ObserveResult(
|
||||
ok=True,
|
||||
message="Screen observed.",
|
||||
screenshot_path=screenshot_path,
|
||||
image_name=Path(screenshot_path).stem if screenshot_path else None,
|
||||
width=width,
|
||||
height=height,
|
||||
elements_text=minimal_list_str,
|
||||
elements=elements,
|
||||
hierarchy_ok=hierarchy_ok,
|
||||
),
|
||||
screenshot_bytes,
|
||||
)
|
||||
Reference in new issue
Block a user