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,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)."""
|
||||
...
|
||||
Reference in new issue
Block a user