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:
weilinliu55 committed 2026-08-27 18:25:27 -07:00
1 parent 60312abbb0
commit cbdca1497c
52 files changed
+5735 -1649

No files matched your search

+546
View File
@@ -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}"
+319
View File
@@ -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,
)
+172
View File
@@ -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}")
+353
View File
@@ -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
+249
View File
@@ -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
+86
View File
@@ -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."""
+21
View File
@@ -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"]
+419
View File
@@ -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()
+113
View File
@@ -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)."""
...
+70
View File
@@ -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
View File
@@ -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")
+133
View File
@@ -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,
)