feat: initial release of ARTEMIS Android Agent & Automation Platform

ARTEMIS is an autonomous multimodal Android agent and mobile UI automation
platform engineered for physical devices and emulators. It combines state-of-the-art
multimodal Vision-Language Models (VLMs) with hierarchical closed-loop planning,
resilient self-healing, and deep Android subsystem integration.

Key Features & Architectural Highlights:
- Dual-Engine Orchestration:
  * Pro Engine: Multi-agent closed-loop (Planner -> Operator -> Validator/Failure Analyzer -> Summarizer) powered by LangGraph.
  * Flash Engine: Ultra-fast reactive loop for deterministic, low-latency UI execution (<35 steps).
- Multimodal Perception & Grounding:
  * Hybrid visual screenshot grounding, real-time OCR extraction, and XML accessibility hierarchy parsing.
  * Dynamic-First, Coordinate-Fallback locating strategy for maximum resilience against UI drift.
- Autonomous Self-Healing & Diagnostics:
  * Failure analyzer and safety net for popup dismissal, crash recovery, and auto-retry.
  * Diagnostic sub-agents for log analysis, video recording inspection, and object detection.
- Unified CLI & Python SDK:
  * Rich CLI with commands: init, doctor, run, trace, batch, bench, server, and mcp.
  * Fluent Python SDK with Pydantic structured output models and package locking.
- Multi-Provider LLM Architecture:
  * Support for Google Gemini, OpenAI, Anthropic Claude, Vertex AI, OpenRouter, xAI Grok, and local endpoints (Ollama/vLLM).
  * Centralized configuration in config/artemis.jsonc.
- Native MCP & Observability:
  * Model Context Protocol (MCP) server endpoints for AI IDEs.
  * Full trace recording with before/after screenshots, action overlays, and execution replays.

Change-Id: I430b93ee2b76b54cc5a3c6eeccd15d2cf29830a1
This commit is contained in:
somew1nd committed 2026-08-13 11:46:47 -07:00
commit 206a9ce921
229 files changed
+49653

No files matched your search

@@ -0,0 +1,15 @@
# 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.
"""CLI command modules."""
+160
View File
@@ -0,0 +1,160 @@
# 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.
"""Batch tasks execution command (artemis batch)."""
import asyncio
import json
from pathlib import Path
from typing import Annotated
from artemis.config import initialize_llm_config
from artemis.sdk import Agent
from artemis.sdk.builders import Builders
from artemis.sdk.types.task import AgentProfile
from artemis.utils.logger import get_logger
from rich.console import Console
from rich.table import Table
import typer
logger = get_logger(__name__)
async def run_batch_tasks(
tasks: list[str],
profile_name: str = "pro",
delay_seconds: float = 5.0,
) -> None:
"""Executes a list of automation tasks sequentially."""
llm_config = initialize_llm_config()
profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile).build()
agent = Agent(config=config)
await agent.init()
console = Console()
results = []
try:
for idx, goal in enumerate(tasks, start=1):
console.rule(f"[bold cyan]Task {idx}/{len(tasks)}: {goal}[/bold cyan]")
status = "SUCCESS"
error_msg = ""
try:
result = await agent.run_task(goal=goal, profile=profile_name)
logger.info(f"Task {idx} completed: {result}")
except Exception as e:
status = "FAILED"
error_msg = str(e)
logger.error(f"Task {idx} failed: {e}")
results.append({"task": goal, "status": status, "error": error_msg})
if idx < len(tasks) and delay_seconds > 0:
await asyncio.sleep(delay_seconds)
finally:
await agent.clean()
# Print summary table
table = Table(title="Batch Execution Summary")
table.add_column("#", justify="right", style="cyan")
table.add_column("Task Goal", style="white")
table.add_column("Status", justify="center")
table.add_column("Notes", style="red")
for i, res in enumerate(results, 1):
status_styled = (
"[bold green]PASS[/bold green]"
if res["status"] == "SUCCESS"
else "[bold red]FAIL[/bold red]"
)
table.add_row(str(i), res["task"], status_styled, res["error"])
console.print(table)
def batch_command(
tasks_file: Annotated[
Path | None,
typer.Option(
"--file",
"-f",
help="Path to a text or JSON file containing task goals (one per line or JSON array).",
),
] = None,
goals: Annotated[
list[str] | None,
typer.Argument(
help="Task goals specified directly on the command line.",
),
] = None,
profile: Annotated[
str,
typer.Option(
"--profile",
"-p",
help="Execution profile to use for all tasks ('flash' or 'pro').",
),
] = "pro",
delay: Annotated[
float,
typer.Option(
"--delay",
"-d",
help="Delay in seconds between successive tasks.",
),
] = 5.0,
) -> None:
"""Execute multiple automation tasks in sequence."""
task_list: list[str] = []
if tasks_file:
if not tasks_file.exists():
typer.secho(f"Error: Tasks file '{tasks_file}' not found.", fg=typer.colors.RED)
raise typer.Exit(1)
content = tasks_file.read_text(encoding="utf-8").strip()
if tasks_file.suffix.lower() == ".json":
try:
parsed = json.loads(content)
if isinstance(parsed, list):
task_list.extend(str(item) for item in parsed)
else:
typer.secho(
"Error: JSON file must contain a list of goals.", fg=typer.colors.RED
)
raise typer.Exit(1)
except Exception as e:
typer.secho(f"Error parsing JSON file: {e}", fg=typer.colors.RED)
raise typer.Exit(1)
else:
task_list.extend(
[
line.strip()
for line in content.splitlines()
if line.strip() and not line.startswith("#")
]
)
if goals:
task_list.extend(goals)
if not task_list:
typer.secho(
"Error: No tasks provided. Use --file or pass goals as arguments.", fg=typer.colors.RED
)
raise typer.Exit(1)
asyncio.run(run_batch_tasks(task_list, profile_name=profile, delay_seconds=delay))
+157
View File
@@ -0,0 +1,157 @@
# 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.
"""Benchmark and evaluation command (artemis bench)."""
import asyncio
from pathlib import Path
import subprocess
import sys
from typing import Annotated
from artemis.config import initialize_llm_config
from artemis.sdk import Agent
from artemis.sdk.builders import Builders
from artemis.sdk.types.task import AgentProfile
from artemis.utils.logger import get_logger
import typer
logger = get_logger(__name__)
def check_and_start_emulator(adb_path: str = "adb") -> bool:
"""Checks if Android emulator is running; optionally starts it."""
try:
res = subprocess.run([adb_path, "devices"], capture_output=True, text=True, timeout=5)
for line in res.stdout.splitlines():
if "emulator-" in line and "device" in line:
return True
except Exception:
pass
return False
async def run_benchmark(
task_name: str | None = None,
profile_name: str = "pro",
console_port: int = 5554,
adb_path: str = "adb",
) -> None:
"""Runs AndroidWorld benchmark task evaluations."""
# Add android_world-main if present
workspace_root = Path(__file__).resolve().parent.parent.parent.parent.parent
aw_path = workspace_root / "android_world-main"
if aw_path.exists() and str(aw_path) not in sys.path:
sys.path.insert(0, str(aw_path))
try:
from android_world import registry
from android_world.env import env_launcher
except ImportError:
logger.error("AndroidWorld benchmark suite is not installed or available in path.")
raise typer.Exit(1)
task_registry = registry.TaskRegistry()
aw_registry = task_registry.get_registry(task_registry.ANDROID_WORLD_FAMILY)
if task_name and task_name not in aw_registry:
logger.error(f"Task '{task_name}' not found in AndroidWorld registry.")
available = sorted(list(aw_registry.keys()))
logger.info(f"Available tasks ({len(available)}): {', '.join(available[:10])}...")
raise typer.Exit(1)
logger.info(f"Initializing AndroidWorld environment on console port {console_port}...")
env = env_launcher.load_and_setup_env(
console_port=console_port,
emulator_setup=False,
adb_path=adb_path,
)
llm_config = initialize_llm_config()
profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile).build()
agent = Agent(config=config)
await agent.init()
tasks_to_run = [task_name] if task_name else list(aw_registry.keys())
try:
for t_name in tasks_to_run:
task_type = aw_registry[t_name]
params = task_type.generate_random_params()
task_instance = task_type(params)
logger.info(f"\n🎯 [Benchmark] Starting: {t_name} | Goal: {task_instance.goal}")
task_instance.initialize_task(env)
result = await agent.run_task(
goal=task_instance.goal,
name=f"bench_{t_name}",
profile=profile_name,
)
success = task_instance.is_successful(env)
logger.info(f"🏆 Task '{t_name}' Result: {result} | Benchmark Verified: {success}")
finally:
await agent.clean()
env.close()
def bench_command(
task: Annotated[
str | None,
typer.Option(
"--task",
"-t",
help="Specific AndroidWorld task name to evaluate (e.g. Clock_1, Calculator_1).",
),
] = None,
profile: Annotated[
str,
typer.Option(
"--profile",
"-p",
help="Agent profile to evaluate ('flash' or 'pro').",
),
] = "pro",
adb_path: Annotated[
str,
typer.Option(
"--adb-path",
help="Path to ADB executable.",
),
] = "adb",
console_port: Annotated[
int,
typer.Option(
"--port",
help="Emulator console port.",
),
] = 5554,
) -> None:
"""Run AndroidWorld benchmark task evaluations against the agent."""
if not check_and_start_emulator(adb_path=adb_path):
typer.secho(
f"Warning: No active emulator detected on port {console_port}. Please start an emulator.",
fg=typer.colors.YELLOW,
)
asyncio.run(
run_benchmark(
task_name=task,
profile_name=profile,
console_port=console_port,
adb_path=adb_path,
)
)
+250
View File
@@ -0,0 +1,250 @@
# 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.
"""System, environment, and device diagnostics (artemis doctor)."""
import shutil
import subprocess
import sys
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from artemis.config import (
parse_llm_config,
settings,
)
def doctor_command() -> None:
"""Run diagnostics to inspect system dependencies, device connectivity, and configuration."""
console = Console()
console.print()
console.print(
Panel(
"[bold cyan]☕ Artemis System & Environment Doctor[/bold cyan]\n"
"[dim]Checking prerequisites for autonomous mobile testing...[/dim]",
expand=False,
)
)
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Component", style="dim", width=22)
table.add_column("Status", width=12)
table.add_column("Details & Recommendations")
all_passed = True
# 1. Python Version
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
if sys.version_info >= (3, 12):
table.add_row(
"Python Runtime",
"[bold green]✔ OK[/bold green]",
f"Python {py_ver} (64-bit)",
)
else:
all_passed = False
table.add_row(
"Python Runtime",
"[bold red]✖ Warning[/bold red]",
f"Python {py_ver} detected. Python >= 3.12 is strongly recommended.",
)
# 2. ADB Installation & Server
adb_path = shutil.which("adb")
if adb_path:
try:
res = subprocess.run(
[adb_path, "version"],
capture_output=True,
text=True,
timeout=5,
)
adb_info = res.stdout.splitlines()[0] if res.stdout else "Installed"
table.add_row(
"Android ADB", "[bold green]✔ OK[/bold green]", f"{adb_path} ({adb_info})"
)
except Exception:
table.add_row("Android ADB", "[bold green]✔ OK[/bold green]", f"{adb_path}")
else:
all_passed = False
table.add_row(
"Android ADB",
"[bold red]✖ Missing[/bold red]",
"adb not found in PATH. Install Android Platform-Tools.",
)
# 3. Connected Android Devices
devices_info: list[tuple[str, str, str]] = []
if adb_path:
try:
res = subprocess.run(
[adb_path, "devices", "-l"],
capture_output=True,
text=True,
timeout=5,
)
lines = [line_str.strip() for line_str in res.stdout.splitlines() if line_str.strip()]
for line in lines[1:]: # skip 'List of devices attached'
parts = line.split()
if len(parts) >= 2:
serial = parts[0]
state = parts[1]
extra = " ".join(parts[2:])
devices_info.append((serial, state, extra))
except Exception:
pass
if devices_info:
active_devices = [d for d in devices_info if d[1] == "device"]
if active_devices:
summary = "\n".join(
[
f"• [bold cyan]{d[0]}[/bold cyan] ({d[2] or 'Authorized'})"
for d in active_devices
]
)
table.add_row("Android Devices", "[bold green]✔ Connected[/bold green]", summary)
else:
all_passed = False
table.add_row(
"Android Devices",
"[bold yellow]⚠ Unauthorized[/bold yellow]",
"Device detected but unauthorized. Check phone screen for USB Debugging permission prompt.",
)
else:
table.add_row(
"Android Devices",
"[bold yellow]⚠ None Found[/bold yellow]",
"No Android device or emulator detected. Connect phone via USB or start emulator.",
)
# 4. LLM & Vision OCR Key Configuration
gemini_key = settings.get_api_key("google")
ocr_key = settings.get_api_key("ocr")
openai_key = settings.get_api_key("openai")
claude_key = settings.get_api_key("anthropic")
openrouter_key = settings.get_api_key("openrouter")
configured_keys = []
if gemini_key:
val = gemini_key.get_secret_value()
masked = f"{val[:6]}...{val[-4:]}" if len(val) > 10 else "***"
configured_keys.append(f"Gemini LLM ({masked})")
if openai_key:
configured_keys.append("OpenAI")
if claude_key:
configured_keys.append("Anthropic")
if openrouter_key:
configured_keys.append("OpenRouter")
if configured_keys:
table.add_row(
"LLM Providers", "[bold green]✔ Configured[/bold green]", ", ".join(configured_keys)
)
else:
all_passed = False
table.add_row(
"LLM Providers",
"[bold red]✖ Missing[/bold red]",
"No LLM key found in .env. Run [bold cyan]artemis init[/bold cyan] to configure in 10 seconds.",
)
# 5. Vision OCR Key Check
if ocr_key:
val = ocr_key.get_secret_value()
masked = f"{val[:6]}...{val[-4:]}" if len(val) > 10 else "***"
table.add_row(
"Vision OCR",
"[bold green]✔ Configured[/bold green]",
f"Google Cloud Vision API ({masked})",
)
else:
table.add_row(
"Vision OCR",
"[bold yellow]⚠ Missing[/bold yellow]",
"OCR_API_KEY / API_KEY not set. Perception fast path will use fallback.",
)
# 6. Configuration File Health
try:
cfg = parse_llm_config()
planner_model = cfg.planner.model
table.add_row(
"Configuration",
"[bold green]✔ Valid[/bold green]",
f"config/artemis.jsonc parsed (Planner: {planner_model})",
)
except Exception as e:
all_passed = False
table.add_row(
"Configuration",
"[bold red]✖ Error[/bold red]",
f"Failed to parse config/artemis.jsonc: {e}",
)
# 7. Video Recording Tools (FFmpeg & scrcpy)
ffmpeg_path = shutil.which("ffmpeg")
scrcpy_path = shutil.which("scrcpy")
if ffmpeg_path:
table.add_row(
"FFmpeg Tools",
"[bold green]✔ Installed[/bold green]",
f"{ffmpeg_path} (Video encoding/trimming ready)",
)
else:
table.add_row(
"FFmpeg Tools",
"[dim]⚪ Optional[/dim]",
"Not found. Install ffmpeg for video compression and audio analysis.",
)
if scrcpy_path:
table.add_row(
"Screen Recorder",
"[bold green]✔ Installed[/bold green]",
f"{scrcpy_path} (Live screen recording ready)",
)
else:
table.add_row(
"Screen Recorder",
"[dim]⚪ Optional[/dim]",
"scrcpy not found. Install scrcpy for --with-video-recording-tools.",
)
console.print(table)
console.print()
if all_passed:
console.print(
Panel(
"[bold green]🎉 All system checks passed![/bold green]\n\n"
"Run your first automation task now:\n"
' [bold cyan]artemis run "Open Settings and check Battery level"[/bold cyan]',
title="Status: Ready",
expand=False,
)
)
else:
console.print(
Panel(
"[bold yellow]💡 Tip:[/bold yellow] Run [bold cyan]artemis init[/bold cyan] to quickly set up your API key and device.",
title="Action Required",
expand=False,
)
)
console.print()
+150
View File
@@ -0,0 +1,150 @@
# 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.
"""Interactive setup wizard for first-time ARTEMIS onboarding (artemis init)."""
import shutil
import subprocess
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from artemis.config import ROOT_DIR
def init_command() -> None:
"""Interactive quickstart wizard to configure API keys and connect your device."""
console = Console()
console.print()
console.print(
Panel(
"[bold cyan]☕ Welcome to Artemis Mobile Testing Framework[/bold cyan]\n"
"[dim]Let's get you set up in 10 seconds![/dim]",
expand=False,
)
)
# 1. Select Provider
console.print("\n[bold]Step 1: Choose Primary LLM Provider[/bold]")
console.print(" [1] Google Gemini (Recommended - Default)")
console.print(" [2] OpenAI (GPT-4o)")
console.print(" [3] Anthropic (Claude 3.5 Sonnet)")
console.print(" [4] OpenRouter")
choice = Prompt.ask("Select provider", choices=["1", "2", "3", "4"], default="1")
provider_map = {
"1": ("GEMINI_API_KEY", "Google Gemini"),
"2": ("OPENAI_API_KEY", "OpenAI"),
"3": ("ANTHROPIC_API_KEY", "Anthropic"),
"4": ("OPEN_ROUTER_API_KEY", "OpenRouter"),
}
env_key, provider_name = provider_map[choice]
# 2. Enter API Key
console.print(f"\n[bold]Step 2: Enter your {provider_name} API Key[/bold]")
api_key = Prompt.ask(f"Enter {env_key}", password=True)
while not api_key.strip():
console.print("[bold red]API Key cannot be empty.[/bold red]")
api_key = Prompt.ask(f"Enter {env_key}", password=True)
# 3. Scan for Android Devices
console.print("\n[bold]Step 3: Detecting Android Devices / Emulators...[/bold]")
detected_devices = []
adb_path = shutil.which("adb")
if adb_path:
try:
res = subprocess.run(
[adb_path, "devices", "-l"],
capture_output=True,
text=True,
timeout=5,
)
lines = [line_str.strip() for line_str in res.stdout.splitlines() if line_str.strip()]
for line in lines[1:]:
parts = line.split()
if len(parts) >= 2 and parts[1] == "device":
serial = parts[0]
extra = " ".join(parts[2:])
detected_devices.append((serial, extra))
except Exception:
pass
selected_serial = ""
if detected_devices:
console.print(
f"[bold green]✔ Found {len(detected_devices)} connected device(s):[/bold green]"
)
for idx, (serial, extra) in enumerate(detected_devices, 1):
console.print(f" [{idx}] {serial} ({extra})")
if len(detected_devices) == 1:
selected_serial = detected_devices[0][0]
console.print(f"[dim]Auto-selected device: {selected_serial}[/dim]")
else:
dev_choice = Prompt.ask(
"Select device index",
choices=[str(i) for i in range(1, len(detected_devices) + 1)],
default="1",
)
selected_serial = detected_devices[int(dev_choice) - 1][0]
else:
console.print(
"[bold yellow]⚠ No connected device detected right now.[/bold yellow]\n"
"[dim]Artemis will auto-detect your device when you connect it later.[/dim]"
)
# 4. Generate .env File
env_content = [
"# ==============================================================================",
"# ☕ ARTEMIS Environment Configuration (Generated by 'artemis init')",
"# ==============================================================================",
f"{env_key}={api_key.strip()}",
"",
"# Android Device Configuration",
"ADB_HOST=127.0.0.1",
"ADB_PORT=5037",
]
if selected_serial:
env_content.append(f"ADB_DEVICE_SERIAL={selected_serial}")
else:
env_content.append("# ADB_DEVICE_SERIAL=emulator-5554")
env_content.extend(
[
"",
"# Execution Defaults",
"ARTEMIS_DEFAULT_PROFILE=pro",
"ARTEMIS_DEFAULT_MODEL=gemini-2.5-flash",
"ARTEMIS_TRACES_DIR=./traces",
"",
]
)
env_path = ROOT_DIR / ".env"
env_path.write_text("\n".join(env_content), encoding="utf-8")
console.print()
console.print(
Panel(
f"[bold green]🎉 Setup completed successfully![/bold green]\n\n"
f"Configured [bold cyan]{provider_name}[/bold cyan] in [dim]{env_path}[/dim]\n\n"
"Try running your first task:\n"
' [bold cyan]artemis run "Open YouTube and search for Lo-Fi Hip Hop"[/bold cyan]\n\n'
"Or run health diagnostics anytime with:\n"
" [bold cyan]artemis doctor[/bold cyan]",
title="☕ Ready to Go!",
expand=False,
)
)
console.print()
+42
View File
@@ -0,0 +1,42 @@
# 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.
"""Model Context Protocol (MCP) server command (artemis mcp)."""
from typing import Annotated
from artemis.mcp.adb_server import mcp as adb_mcp
from artemis.utils.logger import get_logger
import typer
logger = get_logger(__name__)
def mcp_command(
server_type: Annotated[
str,
typer.Option(
"--type",
"-t",
help="Type of MCP server to start ('adb' or 'xml').",
),
] = "adb",
) -> None:
"""Launch a ARTEMIS Model Context Protocol (MCP) server."""
if server_type.lower() == "adb":
logger.info("Starting Artemis ADB MCP Server over stdio...")
adb_mcp.run(transport="stdio")
else:
logger.error(f"Unsupported MCP server type: {server_type}")
raise typer.Exit(1)
+335
View File
@@ -0,0 +1,335 @@
# 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.
"""Task execution command (artemis run)."""
import asyncio
import os
from pathlib import Path
from shutil import which
from typing import Annotated
from adbutils import AdbClient
from langchain_core.callbacks.base import Callbacks
from artemis.config import initialize_llm_config, settings
from artemis import Agent, Builders
from artemis.sdk.types.task import AgentProfile
from artemis.utils.cli_helpers import display_device_status
from artemis.utils.logger import get_logger
from artemis.utils.video import check_ffmpeg_available
from rich.console import Console
from rich.panel import Panel
import typer
logger = get_logger(__name__)
async def execute_task(
goal: str,
locked_app_package: str | None = None,
test_name: str | None = None,
traces_output_path_str: str | None = None,
output_description: str | None = None,
graph_config_callbacks: Callbacks = [],
video_recording_tools_enabled: bool | None = None,
profile: str | None = None,
app_path: str | None = None,
enable_planner_validation: bool | None = None,
enable_committee: bool | None = None,
enable_checker: bool | None = None,
enable_step_summarizer: bool | None = None,
enable_outputter: bool | None = None,
force_output_synthesis: bool | None = None,
explorer_version: str | None = None,
explorer_flash_mode: str | None = None,
explorer_pro_mode: str | None = None,
) -> None:
"""Executes a single mobile automation task end-to-end.
Args:
goal: Target objective to achieve on the mobile device.
locked_app_package: Optional package name to constrain actions to.
test_name: Optional test identifier for trace directory naming.
traces_output_path_str: Destination path for recording traces.
output_description: Structured natural language output description.
graph_config_callbacks: LangGraph callback handlers.
video_recording_tools_enabled: Whether screen video analyzer is enabled.
profile: Execution profile ('flash' for fast reactive loop, 'pro' for full graph).
app_path: Optional local APK path to install prior to execution.
enable_planner_validation: Explicit override to enable/disable async planner validation.
enable_committee: Explicit override to enable/disable Multi-Agent Committee tool.
explorer_version: Override default Explorer version mode ('flash', 'pro', or 'ultra').
explorer_flash_mode: Override Explorer version mode for Flash execution profile.
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
"""
llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
if video_recording_tools_enabled is not None:
config.with_video_recording_tools(enabled=video_recording_tools_enabled)
if enable_planner_validation is not None:
config.with_planner_validation(enabled=enable_planner_validation)
if enable_committee is not None:
config.with_committee(enabled=enable_committee)
if enable_checker is not None:
config.with_checker(enabled=enable_checker)
if enable_step_summarizer is not None:
config.with_flash_step_summarizer(enabled=enable_step_summarizer)
if enable_outputter is not None or force_output_synthesis is not None:
config.with_outputter(
enabled=enable_outputter if enable_outputter is not None else True,
force_synthesis=bool(force_output_synthesis),
)
if (
explorer_version is not None
or explorer_flash_mode is not None
or explorer_pro_mode is not None
):
config.with_explorer(
version=explorer_version,
flash_mode=explorer_flash_mode,
pro_mode=explorer_pro_mode,
)
if settings.ADB_HOST:
config.with_adb_server(host=settings.ADB_HOST, port=settings.ADB_PORT)
if graph_config_callbacks:
config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None
try:
agent = Agent(config=config.build())
await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
)
task = agent.new_task(goal)
if locked_app_package:
task.with_locked_app_package(locked_app_package)
if test_name:
trace_path = traces_output_path_str or str(settings.TRACES_PATH)
task.with_name(test_name).with_trace_recording(path=trace_path)
if output_description:
task.with_output_description(output_description)
if profile:
task.using_profile(profile)
if app_path:
task.with_app_path(Path(app_path))
llm_result_path = os.getenv("RESULTS_OUTPUT_PATH", None)
if llm_result_path:
task.with_llm_output_saving(path=llm_result_path)
await agent.run_task(request=task.build())
finally:
if agent is not None:
await agent.clean()
def run_command(
goal: Annotated[str, typer.Argument(help="The main goal for the agent to achieve.")],
profile: Annotated[
str | None,
typer.Option(
"--profile",
"-p",
help="Execution profile ('flash' for fast reactive, 'pro' for full graph).",
),
] = "pro",
locked_app_package: Annotated[
str | None,
typer.Option(
"--locked-app",
"-a",
help="Optional Android package name to restrict the agent to.",
),
] = None,
test_name: Annotated[
str | None,
typer.Option(
"--test-name",
"-n",
help="Name of the test run for trace recording.",
),
] = None,
traces_path: Annotated[
str | None,
typer.Option(
"--traces-path",
"-t",
help="Directory where execution traces are persisted.",
),
] = None,
output_description: Annotated[
str | None,
typer.Option(
"--output-description",
"-o",
help="Natural language or schema description of the expected output.",
),
] = None,
with_video_recording_tools: Annotated[
bool | None,
typer.Option(
"--with-video-recording-tools/--without-video-recording-tools",
help=(
"Enable or disable dynamic video recording and screen analysis "
"tools (auto-detected if omitted)."
),
),
] = None,
app_path: Annotated[
str | None,
typer.Option(
"--app-path",
help="Local APK path to install before starting the task.",
),
] = None,
enable_planner_validation: Annotated[
bool | None,
typer.Option(
"--enable-planner-validation/--disable-planner-validation",
help="Enable or disable async Planner validation when task plan milestones change.",
),
] = None,
enable_committee: Annotated[
bool | None,
typer.Option(
"--enable-committee/--disable-committee",
help="Enable or disable Multi-Agent Committee council debate tool for Operator.",
),
] = None,
enable_checker: Annotated[
bool | None,
typer.Option(
"--enable-checker/--disable-checker",
help="Enable or disable visual subgoal verification and rollback by the Checker agent.",
),
] = None,
enable_step_summarizer: Annotated[
bool | None,
typer.Option(
"--enable-step-summarizer/--disable-step-summarizer",
help="Enable or disable Flash asynchronous objective visual step state summarizer.",
),
] = None,
enable_outputter: Annotated[
bool | None,
typer.Option(
"--enable-outputter/--disable-outputter",
help="Enable or disable Outputter post-execution report and structured output synthesis.",
),
] = None,
force_output_synthesis: Annotated[
bool | None,
typer.Option(
"--force-output-synthesis/--no-force-output-synthesis",
help="Force Outputter report synthesis even if no structured output schema is specified.",
),
] = None,
explorer_version: Annotated[
str | None,
typer.Option(
"--explorer-version",
help="Default Explorer version mode ('flash' for 1-shot detection, 'pro' for 3-turn ReAct, 'ultra' for deep pixel reasoning).",
),
] = None,
explorer_flash_mode: Annotated[
str | None,
typer.Option(
"--explorer-flash-mode",
help="Explorer version mode when running under Flash profile (FlashRunner).",
),
] = None,
explorer_pro_mode: Annotated[
str | None,
typer.Option(
"--explorer-pro-mode",
help="Explorer version mode when running under Pro profile (Operator/Validator).",
),
] = None,
) -> None:
"""Run an autonomous UI automation task on the connected Android device."""
if with_video_recording_tools:
check_ffmpeg_available()
console = Console()
adb_client = None
try:
if which("adb"):
adb_client = AdbClient(
host=settings.ADB_HOST or "localhost",
port=settings.ADB_PORT or 5037,
)
except Exception:
pass
display_device_status(console, adb_client=adb_client)
cancelled = False
try:
asyncio.run(
execute_task(
goal=goal,
locked_app_package=locked_app_package,
test_name=test_name,
traces_output_path_str=traces_path,
output_description=output_description,
video_recording_tools_enabled=with_video_recording_tools,
profile=profile,
app_path=app_path,
enable_planner_validation=enable_planner_validation,
enable_committee=enable_committee,
enable_checker=enable_checker,
enable_step_summarizer=enable_step_summarizer,
enable_outputter=enable_outputter,
force_output_synthesis=force_output_synthesis,
explorer_version=explorer_version,
explorer_flash_mode=explorer_flash_mode,
explorer_pro_mode=explorer_pro_mode,
)
)
except KeyboardInterrupt:
cancelled = True
except Exception as e:
err_msg = str(e)
if "API_KEY" in err_msg or "requires" in err_msg:
console.print()
console.print(
Panel(
f"[bold red]✖ Authentication Error:[/bold red] {err_msg}\n\n"
"💡 [bold cyan]Quick Fix:[/bold cyan] Run [bold green]artemis init[/bold green] to configure your API key in 10 seconds,\n"
"or add [bold]GEMINI_API_KEY=your_key[/bold] to [dim].env[/dim].",
title="Missing API Key",
expand=False,
)
)
console.print()
raise SystemExit(1)
else:
console.print(f"[bold red]Task execution failed:[/bold red] {e}")
raise
finally:
if cancelled:
raise SystemExit(130)
+137
View File
@@ -0,0 +1,137 @@
# 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.
"""Server and cloud proxy commands (artemis server)."""
import http.server
import socketserver
import subprocess
import time
from typing import Annotated
import urllib.error
import urllib.request
from artemis.utils.logger import get_logger
import typer
logger = get_logger(__name__)
server_app = typer.Typer(help="Cloud proxy and server management commands.")
cached_token = ""
token_expire_time = 0.0
def get_identity_token() -> str:
"""Fetch Google Cloud IAM identity token for authenticated requests."""
global cached_token, token_expire_time
now = time.time()
if not cached_token or now >= token_expire_time:
try:
res = subprocess.check_output(
["gcloud", "auth", "print-identity-token"], text=True
).strip()
cached_token = res
token_expire_time = now + 1800 # cache 30 mins
except Exception as e:
logger.warning(f"Failed to get GCP identity token: {e}")
return ""
return cached_token
class CloudRunProxyHandler(http.server.BaseHTTPRequestHandler):
target_url: str = "https://artemis-cloud-brain-971929466682.us-central1.run.app"
def log_message(self, format, *args):
logger.info(f"[{self.log_date_time_string()}] {format % args}")
def do_GET(self):
self.proxy_request("GET")
def do_POST(self):
self.proxy_request("POST")
def do_DELETE(self):
self.proxy_request("DELETE")
def do_OPTIONS(self):
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "*")
self.end_headers()
def proxy_request(self, method: str):
url = f"{self.target_url}{self.path}"
headers = {}
for k, v in self.headers.items():
if k.lower() not in ("host", "authorization", "content-length"):
headers[k] = v
token = get_identity_token()
if token:
headers["Authorization"] = f"Bearer {token}"
headers["X-Artemis-Token"] = "alice-token"
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
self.send_response(resp.status)
for k, v in resp.getheaders():
if k.lower() not in ("transfer-encoding", "content-encoding", "content-length"):
self.send_header(k, v)
data = resp.read()
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
except urllib.error.HTTPError as e:
self.send_response(e.code)
for k, v in e.headers.items():
if k.lower() not in ("transfer-encoding", "content-encoding", "content-length"):
self.send_header(k, v)
data = e.read()
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(f"Proxy Error: {e}".encode())
@server_app.command("web")
def web_proxy_command(
port: Annotated[int, typer.Option("--port", "-p", help="Local port to bind.")] = 8080,
target: Annotated[
str,
typer.Option("--target", "-t", help="Target Cloud Run URL."),
] = "https://artemis-cloud-brain-971929466682.us-central1.run.app",
) -> None:
"""Start the local authenticated proxy for ARTEMIS Cloud Web Dashboard."""
CloudRunProxyHandler.target_url = target
typer.secho(
f"🚀 Starting Artemis Cloud Web Proxy at http://localhost:{port}", fg=typer.colors.GREEN
)
typer.secho(f"🔗 Forwarding authenticated requests to: {target}", fg=typer.colors.CYAN)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", port), CloudRunProxyHandler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
typer.secho("\n🛑 Stopping server.", fg=typer.colors.YELLOW)
+111
View File
@@ -0,0 +1,111 @@
# 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.
"""Trace inspection and replay commands (artemis trace)."""
import datetime
import json
from pathlib import Path
from typing import Annotated
from artemis.config import settings
from artemis.utils.logger import get_logger
from rich.console import Console
from rich.table import Table
import typer
logger = get_logger(__name__)
trace_app = typer.Typer(help="Inspect, list, and query task execution traces.")
@trace_app.command("list")
def list_traces(
traces_path: Annotated[
Path | None,
typer.Option("--path", "-p", help="Custom traces directory path."),
] = None,
limit: Annotated[int, typer.Option("--limit", "-l", help="Max sessions to list.")] = 20,
) -> None:
"""List recorded execution trace sessions."""
base_dir = traces_path or settings.TRACES_PATH
if not base_dir.exists():
typer.secho(f"No traces found at: {base_dir}", fg=typer.colors.YELLOW)
return
sessions = [d for d in base_dir.iterdir() if d.is_dir() and not d.name.startswith(".")]
sessions.sort(key=lambda x: x.stat().st_mtime, reverse=True)
console = Console()
table = Table(title=f"Recorded Traces ({len(sessions)} total)")
table.add_column("Session Name", style="cyan")
table.add_column("Last Modified", style="green")
table.add_column("Artifacts", style="white")
for s in sessions[:limit]:
artifacts = []
if (s / "recording.mp4").exists() or (s / "recording.mkv").exists():
artifacts.append("🎬 Video")
if (s / "steps.json").exists():
artifacts.append("📋 Steps")
if (s / "notes").exists():
artifacts.append("📝 Notes")
mtime = time_str = time_to_str(s.stat().st_mtime)
table.add_row(s.name, mtime, ", ".join(artifacts) or "Screenshots")
console.print(table)
def time_to_str(ts: float) -> str:
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
@trace_app.command("view")
def view_trace(
session_name: Annotated[str, typer.Argument(help="Name of the trace session directory.")],
traces_path: Annotated[Path | None, typer.Option("--path", "-p")] = None,
) -> None:
"""View step-by-step summary of a recorded trace session."""
base_dir = traces_path or settings.TRACES_PATH
session_dir = base_dir / session_name
if not session_dir.exists():
typer.secho(f"Session '{session_name}' not found in {base_dir}", fg=typer.colors.RED)
raise typer.Exit(1)
steps_file = session_dir / "steps.json"
console = Console()
if steps_file.exists():
try:
steps_data = json.loads(steps_file.read_text(encoding="utf-8"))
table = Table(title=f"Trace Steps: {session_name}")
table.add_column("Step", justify="right", style="cyan")
table.add_column("Action", style="green")
table.add_column("Reasoning / Motivation", style="white")
for step in steps_data:
table.add_row(
str(step.get("step", "-")),
step.get("action", "-"),
step.get("motivation", step.get("thought", "-")),
)
console.print(table)
return
except Exception as e:
logger.warning(f"Could not parse steps.json: {e}")
# If steps.json not present, list files
typer.secho(f"Session files in {session_dir}:", fg=typer.colors.CYAN)
for f in session_dir.iterdir():
typer.echo(f" - {f.name}")