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:
commit
206a9ce921
229 files changed
+49653
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
|
||||
"""ARTEMIS CLI interface package."""
|
||||
|
||||
from artemis.interfaces.cli.main import app, cli
|
||||
|
||||
__all__ = ["app", "cli"]
|
||||
@@ -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."""
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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.
|
||||
|
||||
"""ARTEMIS Unified Command Line Interface (CLI)."""
|
||||
|
||||
import importlib.metadata
|
||||
from typing import Annotated
|
||||
|
||||
from artemis.interfaces.cli.commands.batch import batch_command
|
||||
from artemis.interfaces.cli.commands.bench import bench_command
|
||||
from artemis.interfaces.cli.commands.doctor import doctor_command
|
||||
from artemis.interfaces.cli.commands.init import init_command
|
||||
from artemis.interfaces.cli.commands.mcp import mcp_command
|
||||
from artemis.interfaces.cli.commands.run import run_command
|
||||
from artemis.interfaces.cli.commands.server import server_app
|
||||
from artemis.interfaces.cli.commands.trace import trace_app
|
||||
from artemis.utils.logger import get_logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
import typer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
app = typer.Typer(
|
||||
name="artemis",
|
||||
help="☕ Artemis: Autonomous Multimodal Android Agent & Testing Framework.",
|
||||
add_completion=False,
|
||||
pretty_exceptions_enable=False,
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
# Register subcommands
|
||||
app.command(name="run", help="Execute an autonomous task on a mobile device.")(run_command)
|
||||
app.command(name="init", help="Interactive quickstart wizard to configure API keys & device.")(
|
||||
init_command
|
||||
)
|
||||
app.command(name="doctor", help="Check system prerequisites, device status, and configuration.")(
|
||||
doctor_command
|
||||
)
|
||||
app.command(name="batch", help="Execute a batch sequence of automation tasks.")(batch_command)
|
||||
app.command(name="bench", help="Run AndroidWorld benchmark task evaluations.")(bench_command)
|
||||
app.command(name="mcp", help="Start the Artemis Model Context Protocol (MCP) server.")(mcp_command)
|
||||
app.add_typer(server_app, name="server", help="Cloud Run proxy and web dashboard server.")
|
||||
app.add_typer(trace_app, name="trace", help="Inspect and query execution traces.")
|
||||
|
||||
|
||||
def version_callback(value: bool):
|
||||
if value:
|
||||
try:
|
||||
ver = importlib.metadata.version("artemis")
|
||||
except Exception:
|
||||
ver = "3.6.3"
|
||||
console = Console()
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold cyan]Artemis Agent Platform[/bold cyan] v{ver}\n"
|
||||
"[dim]Autonomous Multimodal Mobile AI Engine[/dim]",
|
||||
title="☕ Artemis",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main_callback(
|
||||
version: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--version",
|
||||
"-v",
|
||||
help="Show Artemis version and exit.",
|
||||
callback=version_callback,
|
||||
is_eager=True,
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
"""ARTEMIS Autonomous Mobile Agent CLI."""
|
||||
pass
|
||||
|
||||
|
||||
def cli():
|
||||
"""Main CLI entrypoint."""
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
Reference in new issue
Block a user