feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization
- Showcase UI Reorganization: * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console - Standalone MCP Server & Notifiers (mcp_server/): * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner) * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing) * Introduce background task runner and trace store for autonomous mobile execution monitoring - Cross-Platform Runtime & Toolchain Support: * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision * Add artemis/toolchain/ with dependency resolution and SDK descriptors * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*) - Core Enhancements & Diagnostics: * Enhance coordinate normalization, OCR API fallback, and perception pipeline * Add credential validation (credentials_validator.py) and system diagnostics * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams - Test Suite Expansion: * Add integration tests for cloud gateway, remote ADB, and Artemis login * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
1 parent
78e478f502
commit
9a5aaf99b7
249 files changed
+37226
-9339
No files matched your search
@@ -18,7 +18,10 @@ import asyncio
|
||||
from typing import Any, Literal
|
||||
from artemis.drivers.base import BaseDeviceDriver
|
||||
from artemis.tools.base import artemis_tool
|
||||
from artemis.utils.coordinates import parse_swipe_parameters
|
||||
from artemis.utils.coordinates import (
|
||||
compute_smart_swipe_coordinates,
|
||||
parse_swipe_parameters,
|
||||
)
|
||||
from artemis.utils.logger import get_logger
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -53,9 +56,7 @@ class SwipeArgs(BaseModel):
|
||||
direction: Literal["up", "down", "left", "right"] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Smart directional swipe ('up', 'down', 'left', 'right') for general page"
|
||||
" scrolling/browsing. Automatically applies safe vectors and a ~40% visual"
|
||||
" overlap anchor: 'up' (drags bottom-to-top, scrolling down to reveal content below),"
|
||||
"Direction for scrolling and swiping: 'up' (drags bottom-to-top, scrolling down to reveal content below),"
|
||||
" 'down' (drags top-to-bottom, scrolling up to reveal content above),"
|
||||
" 'left' (drags right-to-left, scrolling right),"
|
||||
" 'right' (drags left-to-right, scrolling left)."
|
||||
@@ -83,9 +84,9 @@ class SwipeArgs(BaseModel):
|
||||
),
|
||||
)
|
||||
duration: int = Field(
|
||||
400,
|
||||
800,
|
||||
description=(
|
||||
"Swipe/drag duration in milliseconds (default 400). For drag-and-drop, "
|
||||
"Swipe/drag duration in milliseconds (default 800). For drag-and-drop, "
|
||||
"list reordering, or sliding/adjusting sliders (e.g. volume, brightness, SeekBars), "
|
||||
"set duration >= 1000 (e.g. 1500)."
|
||||
),
|
||||
@@ -101,7 +102,11 @@ class PressKeyArgs(BaseModel):
|
||||
|
||||
class WaitForDelayArgs(BaseModel):
|
||||
seconds: float = Field(
|
||||
..., description="Duration in seconds to wait for animations, pages, or state updates."
|
||||
...,
|
||||
description=(
|
||||
"Duration in seconds to wait (e.g., 2 for 2s, 60 for 1 min, 180 for 3 mins)."
|
||||
" Use whenever time needs to elapse for animations, loading, or scheduled delays."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -182,7 +187,7 @@ async def swipe(
|
||||
direction: str | None = None,
|
||||
start: list[int] | None = None,
|
||||
end: list[int] | None = None,
|
||||
duration: int = 400,
|
||||
duration: int = 800,
|
||||
driver: BaseDeviceDriver = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
@@ -200,7 +205,15 @@ async def swipe(
|
||||
kind, target, dur = parse_swipe_parameters(params, default_duration=duration)
|
||||
|
||||
if kind == "direction" and isinstance(target, str):
|
||||
success = await driver.swipe_direction(target, duration_ms=dur)
|
||||
width, height = driver.screen_size
|
||||
sx, sy, ex, ey, smart_dur = compute_smart_swipe_coordinates(
|
||||
direction=target,
|
||||
target=params.get("target"),
|
||||
width=width,
|
||||
height=height,
|
||||
duration=dur,
|
||||
)
|
||||
success = await driver.swipe(sx, sy, ex, ey, duration_ms=smart_dur)
|
||||
return f"Swiped {target}" if success else f"Swipe {target} failed"
|
||||
elif kind == "coords" and isinstance(target, list) and len(target) == 4:
|
||||
width, height = driver.screen_size
|
||||
|
||||
+19
-6
@@ -57,6 +57,10 @@ class ArtemisTool:
|
||||
self.handler = handler
|
||||
self.category = category
|
||||
|
||||
def is_available(self, ctx: Any = None) -> bool:
|
||||
"""Determines if this tool is currently available and should be exposed to agents."""
|
||||
return True
|
||||
|
||||
async def execute(self, driver: BaseDeviceDriver, ctx: Any, **kwargs: Any) -> Any:
|
||||
"""Executes the tool handler with injected driver and context."""
|
||||
# Inspect handler signature to only pass supported arguments
|
||||
@@ -213,25 +217,32 @@ class ToolRegistry:
|
||||
return cls._tools.get(normalized)
|
||||
|
||||
@classmethod
|
||||
def list_tools(cls, category: ToolCategory | None = None) -> list[ArtemisTool]:
|
||||
"""Lists all registered tools, optionally filtered by category."""
|
||||
def list_tools(
|
||||
cls, category: ToolCategory | None = None, available_only: bool = True
|
||||
) -> list[ArtemisTool]:
|
||||
"""Lists all registered tools, optionally filtered by category and availability."""
|
||||
tools = [t for t in cls._tools.values() if not available_only or t.is_available()]
|
||||
if category:
|
||||
return [t for t in cls._tools.values() if t.category == category]
|
||||
return list(cls._tools.values())
|
||||
return [t for t in tools if t.category == category]
|
||||
return list(tools)
|
||||
|
||||
@classmethod
|
||||
def get_langchain_tools(cls, ctx: Any, names: list[str] | None = None) -> list[BaseTool]:
|
||||
def get_langchain_tools(
|
||||
cls, ctx: Any, names: list[str] | None = None, available_only: bool = True
|
||||
) -> list[BaseTool]:
|
||||
"""Returns LangChain tool wrappers for the requested or all registered tools."""
|
||||
tools = (
|
||||
[cls._tools[n] for n in names if n in cls._tools]
|
||||
if names
|
||||
else list(cls._tools.values())
|
||||
)
|
||||
if available_only:
|
||||
tools = [t for t in tools if t.is_available(ctx)]
|
||||
return [t.to_langchain_tool(ctx) for t in tools]
|
||||
|
||||
@classmethod
|
||||
def get_genai_declarations(
|
||||
cls, names: list[str] | None = None
|
||||
cls, names: list[str] | None = None, available_only: bool = True
|
||||
) -> list[genai_types.FunctionDeclaration]:
|
||||
"""Returns Google GenAI function declarations for registered tools."""
|
||||
tools = (
|
||||
@@ -239,6 +250,8 @@ class ToolRegistry:
|
||||
if names
|
||||
else list(cls._tools.values())
|
||||
)
|
||||
if available_only:
|
||||
tools = [t for t in tools if t.is_available()]
|
||||
return [t.to_genai_declaration() for t in tools]
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -26,6 +26,8 @@ def get_tools_from_wrappers(
|
||||
"""Instantiate and wrap LangChain tools from a list of ToolWrappers."""
|
||||
tools: list[BaseTool] = []
|
||||
for wrapper in wrappers:
|
||||
if wrapper.is_available_fn is not None and not wrapper.is_available_fn(ctx):
|
||||
continue
|
||||
if isinstance(wrapper, CompositeToolWrapper):
|
||||
comp_tools = wrapper.composite_tools_fn_getter(ctx)
|
||||
for t in comp_tools:
|
||||
|
||||
@@ -29,7 +29,10 @@ from artemis.graph.state import State
|
||||
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
|
||||
from artemis.tools.tool_wrapper import ToolWrapper
|
||||
from artemis.tools.types import CyFunctionDetector
|
||||
from artemis.utils.coordinates import parse_swipe_parameters
|
||||
from artemis.utils.coordinates import (
|
||||
compute_smart_swipe_coordinates,
|
||||
parse_swipe_parameters,
|
||||
)
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -84,9 +87,7 @@ class SwipeArgs(BaseModel):
|
||||
direction: Literal["up", "down", "left", "right"] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Smart directional swipe ('up', 'down', 'left', 'right') for general page"
|
||||
" scrolling/browsing. Automatically applies safe vectors and a ~40% visual"
|
||||
" overlap anchor: 'up' (drags bottom-to-top, scrolling down to reveal content below),"
|
||||
"Direction for scrolling and swiping: 'up' (drags bottom-to-top, scrolling down to reveal content below),"
|
||||
" 'down' (drags top-to-bottom, scrolling up to reveal content above),"
|
||||
" 'left' (drags right-to-left, scrolling right),"
|
||||
" 'right' (drags left-to-right, scrolling left)."
|
||||
@@ -106,6 +107,12 @@ class SwipeArgs(BaseModel):
|
||||
" local interactions (e.g. adjusting sliders, SeekBars, fine range selection, or drag-and-drop)."
|
||||
),
|
||||
)
|
||||
target: int | list[int] | str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional target element index (e.g. 2) or container bounds [left, top, right, bottom] to scope the directional swipe within."
|
||||
),
|
||||
)
|
||||
action: Literal["up", "down", "left", "right"] | list[int] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
@@ -114,9 +121,9 @@ class SwipeArgs(BaseModel):
|
||||
),
|
||||
)
|
||||
duration: int = Field(
|
||||
400,
|
||||
800,
|
||||
description=(
|
||||
"Optional swipe/drag duration in milliseconds (default 400). For drag-and-drop,"
|
||||
"Optional swipe/drag duration in milliseconds (default 800). For drag-and-drop,"
|
||||
" list reordering, or sliding/adjusting sliders (e.g., volume, brightness, SeekBars),"
|
||||
" set duration >= 1000 (e.g. 1500). If omitted for directional swipe, duration is computed automatically."
|
||||
),
|
||||
@@ -438,16 +445,19 @@ def get_input_text_tool(ctx: ArtemisContext) -> BaseTool:
|
||||
SWIPE_DOCSTRING = (
|
||||
"[ACTION] Perform a swipe, drag, or slider-adjustment gesture on the"
|
||||
" screen.\n\n"
|
||||
"• Smart Directional Scrolling ('direction'): Best for general browsing"
|
||||
" and page scrolling. Automatically computes safe swipe vectors (locking"
|
||||
" axes to prevent edge/sidebar mis-clicks), retains a ~40% visual overlap"
|
||||
" anchor for zero-omission traversal, and prevents inertial flings.\n"
|
||||
"• Directional Scrolling ('direction'): Recommended for general"
|
||||
" browsing and standard page scrolling in most scenarios. Automatically"
|
||||
" computes safe swipe vectors and adaptive duration, retains a ~40% visual overlap"
|
||||
" anchor for zero-omission traversal, and prevents inertial flings. Supports"
|
||||
" scoping to a sub-container via 'target'. If it fails on certain custom layouts,"
|
||||
" fall back to specifying exact coordinates ('start' and 'end') directly.\n"
|
||||
"• Precise Coordinate Gestures ('start', 'end'): Best for local,"
|
||||
" fine-grained interactions such as adjusting sliders/SeekBars (e.g.,"
|
||||
" volume, brightness, progress bars) or drag-and-drop / list reordering."
|
||||
" Always drag slightly PAST the target position to overcome touch slop"
|
||||
" and reliably trigger the update. When setting a slider to Maximum (100%)"
|
||||
" or Minimum (0%), swipe fully to the extreme boundary."
|
||||
" volume, brightness, progress bars), drag-and-drop / list reordering,"
|
||||
" or as a reliable fallback when directional scrolling fails on specific"
|
||||
" containers. Always drag slightly PAST the target position to overcome"
|
||||
" touch slop and reliably trigger the update. When setting a slider to"
|
||||
" Maximum (100%) or Minimum (0%), swipe fully to the extreme boundary."
|
||||
)
|
||||
|
||||
|
||||
@@ -471,7 +481,7 @@ class SwipeTool(ArtemisTool):
|
||||
direction: Literal["up", "down", "left", "right"] | str | None = None,
|
||||
start: list[int] | None = None,
|
||||
end: list[int] | None = None,
|
||||
duration: int = 400,
|
||||
duration: int = 800,
|
||||
tool_call_id: str | None = None,
|
||||
state: State | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -488,7 +498,7 @@ class SwipeTool(ArtemisTool):
|
||||
if duration is not None:
|
||||
params["duration"] = duration
|
||||
|
||||
kind, target, dur = parse_swipe_parameters(params, default_duration=duration or 400)
|
||||
kind, target, dur = parse_swipe_parameters(params, default_duration=duration or 800)
|
||||
|
||||
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
|
||||
st = state if state is not None else kwargs.get("state")
|
||||
@@ -534,38 +544,18 @@ class SwipeTool(ArtemisTool):
|
||||
controller = UnifiedMobileController(ctx)
|
||||
|
||||
if kind == "direction" and isinstance(target, str):
|
||||
if target == "up":
|
||||
coords = [
|
||||
width // 2,
|
||||
int(height * 0.8),
|
||||
width // 2,
|
||||
int(height * 0.2),
|
||||
]
|
||||
elif target == "down":
|
||||
coords = [
|
||||
width // 2,
|
||||
int(height * 0.2),
|
||||
width // 2,
|
||||
int(height * 0.8),
|
||||
]
|
||||
elif target == "left":
|
||||
coords = [
|
||||
int(width * 0.8),
|
||||
height // 2,
|
||||
int(width * 0.2),
|
||||
height // 2,
|
||||
]
|
||||
elif target == "right":
|
||||
coords = [
|
||||
int(width * 0.2),
|
||||
height // 2,
|
||||
int(width * 0.8),
|
||||
height // 2,
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"Invalid direction: {target}")
|
||||
|
||||
start_x, start_y, end_x, end_y = coords
|
||||
indexed_elems = getattr(state, "indexed_elements", None) if state else None
|
||||
ui_hier = getattr(state, "ui_tree", None) if state else None
|
||||
start_x, start_y, end_x, end_y, smart_dur = compute_smart_swipe_coordinates(
|
||||
direction=target,
|
||||
target=params.get("target"),
|
||||
indexed_elements=indexed_elems,
|
||||
ui_hierarchy=ui_hier,
|
||||
width=width,
|
||||
height=height,
|
||||
duration=dur,
|
||||
)
|
||||
dur = smart_dur
|
||||
elif kind == "coords" and isinstance(target, list) and len(target) == 4:
|
||||
nx1, ny1, nx2, ny2 = target
|
||||
start_x = int(max(0, min(width - 1, nx1 * width / 1000)))
|
||||
|
||||
@@ -30,7 +30,7 @@ from artemis.graph.state import State
|
||||
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
|
||||
from artemis.tools.tool_wrapper import ToolWrapper
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.ocr_api import perform_ocr
|
||||
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
|
||||
|
||||
|
||||
class OcrArgs(BaseModel):
|
||||
@@ -144,6 +144,9 @@ class OcrRecognitionTool(ArtemisTool):
|
||||
category=category,
|
||||
)
|
||||
|
||||
def is_available(self, ctx: Any = None) -> bool:
|
||||
return is_ocr_configured()
|
||||
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
async def execute(
|
||||
self,
|
||||
@@ -207,4 +210,5 @@ ocr_recognition_wrapper = ToolWrapper(
|
||||
tool_fn_getter=get_ocr_tool,
|
||||
on_success_fn=lambda output: f"OCR Recognition successful. Results:\n{output}",
|
||||
on_failure_fn=lambda error: f"OCR Recognition failed: {error}",
|
||||
is_available_fn=lambda ctx: is_ocr_configured(),
|
||||
)
|
||||
@@ -40,6 +40,7 @@ class ToolWrapper(BaseModel):
|
||||
tool_fn_getter: Callable[[ArtemisContext], BaseTool]
|
||||
on_success_fn: Callable[..., str]
|
||||
on_failure_fn: Callable[..., str]
|
||||
is_available_fn: Callable[[ArtemisContext], bool] | None = None
|
||||
|
||||
|
||||
class CompositeToolWrapper(ToolWrapper):
|
||||
|
||||
@@ -62,7 +62,7 @@ class Target(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _default_indices(self):
|
||||
# Treat empty strings like "not provided"
|
||||
# Treat empty strings like “not provided”
|
||||
if (
|
||||
self.resource_id is not None and self.resource_id != ""
|
||||
) and self.resource_id_index is None:
|
||||
|
||||
Reference in new issue
Block a user