feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization

- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
somew1nd committed 2026-08-18 13:59:27 -07:00
1 parent 78e478f502
commit 9a5aaf99b7
249 files changed
+37226 -9339

No files matched your search

@@ -0,0 +1,449 @@
# 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.
"""Android ADB & Device Connectivity Probe with AVD Detection."""
import asyncio
import os
from pathlib import Path
import shutil
import subprocess
from typing import Any
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
DeviceInfo,
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class AdbDeviceProbe(BaseProbe):
"""Deep inspection probe for Android Debug Bridge, connected mobile devices, and local AVD emulators."""
def __init__(self, target_serial: str | None = None):
self._target_serial = target_serial
@property
def probe_id(self) -> str:
return "android_adb"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.DEVICE
@property
def is_blocker(self) -> bool:
return True
def set_target_serial(self, serial: str | None) -> None:
"""Set or update preferred active device serial."""
self._target_serial = serial
def _locate_adb(self) -> str | None:
"""Find the adb binary path from ToolchainResolver."""
return toolchain.resolve("adb", force_refresh=True)
def _locate_emulator(self) -> str | None:
"""Find the emulator binary path from PATH or standard SDK environments."""
emu_path = shutil.which("emulator")
if emu_path:
return emu_path
sdk_candidates = [
os.environ.get("ANDROID_HOME"),
os.environ.get("ANDROID_SDK_ROOT"),
str(Path.home() / "Android" / "Sdk"),
str(Path.home() / "Android" / "sdk"),
str(Path.home() / "Library" / "Android" / "sdk"),
os.getenv("LOCALAPPDATA", "") + "/Android/Sdk" if os.getenv("LOCALAPPDATA") else None,
"/usr/lib/android-sdk",
"/opt/android-sdk",
]
for base in sdk_candidates:
if base:
cand = (
Path(base)
/ "emulator"
/ ("emulator.exe" if platform.os_type == OSType.WINDOWS else "emulator")
)
if cand.is_file():
return str(cand)
return None
def _list_installed_avds(self, emulator_path: str | None = None) -> list[str]:
"""Discover all locally created Android Virtual Devices (AVDs)."""
avd_names: list[str] = []
# Method 1: Scan ~/.android/avd/*.ini
avd_dir = Path.home() / ".android" / "avd"
if avd_dir.is_dir():
for ini_file in avd_dir.glob("*.ini"):
name = ini_file.stem
if name and name not in avd_names:
avd_names.append(name)
return avd_names
async def _get_adb_version(self, adb_path: str) -> str:
"""Query ADB version string."""
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
"version",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if stdout:
first_line = stdout.decode(errors="replace").strip().splitlines()[0]
return first_line
except Exception as e:
logger.debug(f"Failed to read adb version: {e}")
return "Installed"
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
"""Execute `adb devices -l` and extract structured device metadata."""
devices: list[DeviceInfo] = []
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
"devices",
"-l",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0 or not stdout:
return devices
lines = [
line.strip()
for line in stdout.decode(errors="replace").splitlines()
if line.strip()
]
for line in lines[1:]: # Skip "List of devices attached"
parts = line.split()
if len(parts) < 2:
continue
serial = parts[0]
state = parts[1]
extra_tokens = parts[2:]
model = None
product = None
for token in extra_tokens:
if token.startswith("model:"):
model = token.split(":", 1)[1].replace("_", " ")
elif token.startswith("product:"):
product = token.split(":", 1)[1]
is_emulator = (
serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
)
if is_emulator and not model:
model = "Android Emulator"
device_info = DeviceInfo(
serial=serial,
state=state,
model=model,
product=product,
is_emulator=is_emulator,
)
devices.append(device_info)
except Exception as e:
logger.error(f"Error querying adb devices: {e}")
# For authorized devices, attempt non-blocking quick property enrichment
for dev in devices:
if dev.state == "device":
try:
p_prop = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"getprop ro.build.version.release",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
p_size = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"wm size",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out_prop, _ = await p_prop.communicate()
out_size, _ = await p_size.communicate()
if out_prop:
dev.android_version = out_prop.decode(errors="replace").strip()
if out_size:
size_str = out_size.decode(errors="replace").strip()
if "Physical size:" in size_str:
dev.screen_resolution = size_str.split("Physical size:")[-1].strip()
# Query installed application packages with timeout protection
try:
p_pkg = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"pm list packages",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out_pkg, _ = await asyncio.wait_for(p_pkg.communicate(), timeout=2.5)
if out_pkg:
pkgs = [
line.split("package:", 1)[1].strip()
for line in out_pkg.decode(errors="replace").splitlines()
if line.startswith("package:")
and line.split("package:", 1)[1].strip()
]
dev.installed_packages = pkgs
except Exception as e_pkg:
logger.debug(f"Failed to query packages for {dev.serial}: {e_pkg}")
except Exception:
pass
return devices
async def probe(self) -> ProbeResult:
"""Run complete ADB, device, and local AVD connectivity probe."""
adb_path = self._locate_adb()
emulator_path = self._locate_emulator()
installed_avds = self._list_installed_avds(emulator_path)
# 1. ADB binary check
if not adb_path:
actions = []
if platform.os_type == OSType.WINDOWS:
actions.append(
ProbeAction(
action_type="command",
label="Install via WinGet",
payload="winget install Google.PlatformTools",
)
)
actions.append(
ProbeAction(
action_type="command",
label="Run PowerShell Setup",
payload="powershell -ExecutionPolicy Bypass -File scripts/install_deps.ps1",
)
)
elif platform.os_type == OSType.MACOS:
actions.append(
ProbeAction(
action_type="command",
label="Install via Homebrew",
payload="brew install android-platform-tools",
)
)
else:
actions.append(
ProbeAction(
action_type="command",
label="Linux Quick Install",
payload="sudo apt-get install -y adb",
)
)
actions.append(
ProbeAction(
action_type="command",
label="One-Click Install All",
payload="bash scripts/install_deps.sh",
)
)
actions.append(
ProbeAction(
action_type="link",
label="Download Android Platform Tools",
payload="https://developer.android.com/tools/releases/platform-tools",
)
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="ADB Not Found",
description="Android Debug Bridge (adb) is not installed or not in PATH.",
metadata={"installed": False},
actions=actions,
)
adb_ver = await self._get_adb_version(adb_path)
devices = await self._parse_adb_devices(adb_path)
# Normalize emulator command display
is_emu_in_path = shutil.which("emulator") is not None
emu_display_cmd = "emulator" if is_emu_in_path else (emulator_path or "emulator")
metadata: dict[str, Any] = {
"installed": True,
"adb_path": adb_path,
"adb_version": adb_ver,
"emulator_path": emulator_path,
"is_emulator_in_path": is_emu_in_path,
"installed_avds": installed_avds,
"device_count": len(devices),
"devices": [d.model_dump() for d in devices],
}
# 2. Case: No devices found
if not devices:
actions: list[ProbeAction] = []
# If user has installed AVDs, generate exact launch commands for each
if installed_avds:
for avd in installed_avds:
actions.append(
ProbeAction(
action_type="command",
label=f"Launch {avd}",
payload=f"{emu_display_cmd} -avd {avd}",
)
)
else:
actions.append(
ProbeAction(
action_type="command",
label="Start Default Emulator",
payload=f"{emu_display_cmd} -avd Pixel_8_API_34",
)
)
actions.append(
ProbeAction(
action_type="hint",
label="Connect via USB",
payload="Connect your Android phone via USB cable and enable Developer Options -> USB Debugging.",
)
)
desc = "ADB is ready, but no active Android device or emulator was detected."
if installed_avds:
desc += f" Found {len(installed_avds)} installed emulator(s): {', '.join(installed_avds)}."
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="No Device Found",
description=desc,
metadata=metadata,
actions=actions,
)
# 3. Check for authorized and ready devices
ready_devices = [d for d in devices if d.state == "device"]
unauthorized_devices = [d for d in devices if d.state == "unauthorized"]
if not ready_devices:
if unauthorized_devices:
unauth_serial = unauthorized_devices[0].serial
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="Device Unauthorized",
description=f"Device detected ({unauth_serial}), but USB debugging is not yet authorized.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Authorize USB Debugging",
payload="Please unlock your Android phone and tap 'Allow' on the USB Debugging permission prompt.",
),
ProbeAction(
action_type="command",
label="Restart ADB Server",
payload=f"{adb_path} kill-server && {adb_path} start-server",
),
],
)
else:
first_serial = devices[0].serial
first_state = devices[0].state
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="Device Booting",
description=f"Device detected ({first_serial}, state: {first_state}). It is currently booting up or offline. Please wait a few seconds...",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Device Booting",
payload=f"Device {first_serial} is initializing. It will be ready automatically in a few seconds.",
)
],
)
# 4. Ready device available
active_dev = ready_devices[0]
if self._target_serial:
matched = next((d for d in ready_devices if d.serial == self._target_serial), None)
if matched:
active_dev = matched
display_name = active_dev.model or active_dev.serial
if active_dev.screen_resolution:
display_name = f"{display_name} ({active_dev.screen_resolution})"
metadata["active_device"] = active_dev.model_dump()
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Connected",
description=f"ADB connected to {display_name}. Ready for perception and touch automation.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Device Ready",
payload=f"Serial: {active_dev.serial} | Android OS: {active_dev.android_version or 'Unknown'}",
)
],
)
+45
View File
@@ -0,0 +1,45 @@
# 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.
"""Abstract Base Probe for Artemis System Diagnostics."""
from abc import ABC, abstractmethod
from artemis.core.diagnostics.schema import ProbeCategory, ProbeResult
class BaseProbe(ABC):
"""Abstract interface representing a system readiness probe."""
@property
@abstractmethod
def probe_id(self) -> str:
"""Unique identifier for this probe."""
pass
@property
@abstractmethod
def category(self) -> ProbeCategory:
"""Category to which this probe belongs."""
pass
@property
@abstractmethod
def is_blocker(self) -> bool:
"""Whether a failure in this probe should block autonomous task execution."""
pass
@abstractmethod
async def probe(self) -> ProbeResult:
"""Execute the probe inspection and return a structured ProbeResult."""
pass
@@ -0,0 +1,296 @@
# 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.
"""LLM & Multimodal Vision Credentials Readiness Probe."""
from typing import Any
from artemis.config import settings
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
class LLMCredentialsProbe(BaseProbe):
"""Probe verifying Gemini and multi-provider multimodal LLM credentials."""
@property
def probe_id(self) -> str:
return "gemini_api_key"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.CREDENTIALS
@property
def is_blocker(self) -> bool:
return True
def _mask_key(self, key_str: str) -> str:
"""Helper to safely mask an API credential for display."""
if len(key_str) > 10:
return f"{key_str[:6]}...{key_str[-4:]}"
return "***"
async def probe(self) -> ProbeResult:
import os
from artemis.config.settings import is_placeholder_key
gemini_key = settings.get_api_key("google")
openai_key = settings.get_api_key("openai")
claude_key = settings.get_api_key("anthropic")
openrouter_key = settings.get_api_key("openrouter")
xai_key = settings.get_api_key("xai")
ocr_key = settings.get_api_key("ocr")
configured_providers: list[dict[str, Any]] = []
api_keys_map: dict[str, str] = {}
if gemini_key and not is_placeholder_key(gemini_key):
g_val = gemini_key.get_secret_value()
configured_providers.append(
{
"provider": "google",
"label": "Gemini",
"masked": self._mask_key(g_val),
"raw_key": g_val,
"key": g_val,
}
)
api_keys_map["google"] = g_val
api_keys_map["gemini"] = g_val
if openai_key and not is_placeholder_key(openai_key):
o_val = openai_key.get_secret_value()
configured_providers.append(
{
"provider": "openai",
"label": "ChatGPT",
"masked": self._mask_key(o_val),
"raw_key": o_val,
"key": o_val,
}
)
api_keys_map["openai"] = o_val
if claude_key and not is_placeholder_key(claude_key):
c_val = claude_key.get_secret_value()
configured_providers.append(
{
"provider": "anthropic",
"label": "Claude",
"masked": self._mask_key(c_val),
"raw_key": c_val,
"key": c_val,
}
)
api_keys_map["anthropic"] = c_val
if openrouter_key and not is_placeholder_key(openrouter_key):
or_val = openrouter_key.get_secret_value()
configured_providers.append(
{
"provider": "openrouter",
"label": "OpenRouter",
"masked": self._mask_key(or_val),
"raw_key": or_val,
"key": or_val,
}
)
api_keys_map["openrouter"] = or_val
if xai_key and not is_placeholder_key(xai_key):
x_val = xai_key.get_secret_value()
configured_providers.append(
{
"provider": "xai",
"label": "xAI (Grok)",
"masked": self._mask_key(x_val),
"raw_key": x_val,
"key": x_val,
}
)
api_keys_map["xai"] = x_val
# Detect any custom model endpoints or environment variables defined in files
for env_var, label, prov_id in [
("DEEPSEEK_API_KEY", "DeepSeek", "deepseek"),
("GROQ_API_KEY", "Groq", "groq"),
("OPENAI_BASE_URL", "Custom OpenAI Endpoint", "custom"),
("OLLAMA_BASE_URL", "Local Ollama", "ollama"),
("VLLM_BASE_URL", "vLLM Endpoint", "vllm"),
("VERTEX_AI_PROJECT", "Google Cloud Vertex AI", "vertexai"),
]:
val = os.environ.get(env_var)
if val and val.strip() and not is_placeholder_key(val.strip()):
configured_providers.append(
{
"provider": prov_id,
"label": label,
"masked": self._mask_key(val.strip()),
"raw_key": val.strip(),
"key": val.strip(),
}
)
api_keys_map[prov_id] = val.strip()
if ocr_key and not is_placeholder_key(ocr_key):
api_keys_map["ocr"] = ocr_key.get_secret_value()
current_active_key = (
gemini_key.get_secret_value()
if gemini_key
else (configured_providers[0]["key"] if configured_providers else "")
)
metadata = {
"configured_count": len(configured_providers),
"providers": configured_providers,
"has_ocr_key": ocr_key is not None,
"current_key": current_active_key,
"current_gemini_key": gemini_key.get_secret_value() if gemini_key else "",
"api_keys": api_keys_map,
}
# Case 1: Gemini API Key configured (Standard / Recommended)
if gemini_key:
masked = self._mask_key(gemini_key.get_secret_value())
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Multimodal LLM API Key",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Active (Gemini)",
description=f"Gemini multimodal API credential is active ({masked}) and ready for vision and reasoning.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Provider Active",
payload="Gemini 2.5 Flash / Pro Multimodal Engine is enabled.",
)
],
)
# Case 2: Other LLM provider configured
if configured_providers:
first_p = configured_providers[0]
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Multimodal LLM API Key",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary=f"Active ({first_p['label']})",
description=f"{first_p['label']} multimodal API credential is active ({first_p['masked']}).",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Provider Active",
payload=f"{first_p['label']} multimodal vision endpoint is active.",
)
],
)
# Case 3: No LLM key configured
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Multimodal LLM API Key",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="Key Missing",
description="No Multimodal LLM credential (e.g. GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) found in environment or .env file.",
metadata=metadata,
actions=[
ProbeAction(
action_type="command",
label="Run Artemis Init",
payload="artemis init",
),
ProbeAction(
action_type="link",
label="Get Google AI Studio Key",
payload="https://aistudio.google.com/app/apikey",
),
],
)
class VisionOCRProbe(BaseProbe):
"""Probe verifying optional Google Cloud Vision OCR credentials."""
@property
def probe_id(self) -> str:
return "vision_ocr_key"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.CREDENTIALS
@property
def is_blocker(self) -> bool:
return False
def _mask_key(self, key_str: str) -> str:
"""Helper to safely mask an API credential for display."""
if len(key_str) > 10:
return f"{key_str[:6]}...{key_str[-4:]}"
return "***"
async def probe(self) -> ProbeResult:
from artemis.utils.ocr_api import is_ocr_configured
ocr_key = settings.get_api_key("ocr")
is_configured = is_ocr_configured() and ocr_key is not None
if is_configured and ocr_key:
val = ocr_key.get_secret_value()
masked = self._mask_key(val)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Vision OCR API Key (Optional)",
status=ProbeStatus.PASS,
is_blocker=False,
summary="Active & Configured",
description=f"Google Cloud Vision OCR ({masked}) is active for image text recognition.",
metadata={"configured": True, "masked_key": masked, "key": val, "raw_key": val},
actions=[
ProbeAction(
action_type="hint",
label="OCR Enabled",
payload="OCR is active and will fuse text with UI XML hierarchy.",
)
],
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Vision OCR API Key (Optional)",
status=ProbeStatus.PASS,
is_blocker=False,
summary="Not Configured (Optional)",
description="OCR_API_KEY is not set. Perception operates seamlessly in pure UI XML hierarchy mode.",
metadata={"configured": False},
actions=[
ProbeAction(
action_type="hint",
label="Standard XML Perception",
payload="Artemis uses pure UI layout parsing and Set-of-Marks visual grounding.",
)
],
)
@@ -0,0 +1,169 @@
# 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.
"""Python Runtime and System Configuration Diagnostic Probes."""
import platform
import sys
from artemis.config import parse_llm_config
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
class PythonRuntimeProbe(BaseProbe):
"""Probe inspecting Python runtime environment, version, and architecture."""
@property
def probe_id(self) -> str:
return "python_runtime"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.RUNTIME
@property
def is_blocker(self) -> bool:
return True
async def probe(self) -> ProbeResult:
py_ver_tuple = sys.version_info
py_ver_str = f"{py_ver_tuple.major}.{py_ver_tuple.minor}.{py_ver_tuple.micro}"
is_supported = py_ver_tuple >= (3, 12)
arch = platform.machine()
executable = sys.executable
metadata = {
"version": py_ver_str,
"major": py_ver_tuple.major,
"minor": py_ver_tuple.minor,
"micro": py_ver_tuple.micro,
"executable": executable,
"architecture": arch,
"platform": sys.platform,
"is_supported": is_supported,
}
if is_supported:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Python Runtime Environment",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary=f"Python {py_ver_str} Ready",
description=f"Python {py_ver_str} ({arch}, 64-bit) detected at {executable}. Satisfies requirement (>= 3.12).",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Python Runtime OK",
payload=f"Python {py_ver_str} ({arch})",
)
],
)
else:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Python Runtime Environment",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary=f"Python {py_ver_str} Unsupported",
description=f"Python {py_ver_str} detected. Artemis requires Python >= 3.12.",
metadata=metadata,
actions=[
ProbeAction(
action_type="link",
label="Download Python 3.12+",
payload="https://www.python.org/downloads/",
),
ProbeAction(
action_type="command",
label="Install with uv",
payload="uv python install 3.12",
),
],
)
class SystemConfigProbe(BaseProbe):
"""Probe verifying config/artemis.jsonc and settings integrity."""
@property
def probe_id(self) -> str:
return "system_config"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.RUNTIME
@property
def is_blocker(self) -> bool:
return True
async def probe(self) -> ProbeResult:
try:
cfg = parse_llm_config()
planner_model = getattr(getattr(cfg, "planner", None), "model", "Gemini 2.5 Flash")
metadata = {
"valid": True,
"config_file": "config/artemis.jsonc",
"planner_model": planner_model,
}
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Artemis System Configuration",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Config Valid",
description=f"System configuration loaded and validated (Planner model: {planner_model}).",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Config Healthy",
payload="config/artemis.jsonc is loaded and valid.",
)
],
)
except Exception as e:
metadata = {
"valid": False,
"config_file": "config/artemis.jsonc",
"error": str(e),
}
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Artemis System Configuration",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="Config Error",
description=f"Failed to parse config/artemis.jsonc: {e}",
metadata=metadata,
actions=[
ProbeAction(
action_type="command",
label="Run Artemis Init",
payload="artemis init",
)
],
)
@@ -0,0 +1,139 @@
# 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.
"""Toolchain and Video Recording Auxiliary Probe."""
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
class ToolchainProbe(BaseProbe):
"""Probe checking required multimedia and video recording toolchains (ffmpeg, scrcpy)."""
@property
def probe_id(self) -> str:
return "toolchain"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.TOOLCHAIN
@property
def is_blocker(self) -> bool:
return False
async def probe(self) -> ProbeResult:
ffmpeg_path = toolchain.resolve("ffmpeg", force_refresh=True)
scrcpy_path = toolchain.resolve("scrcpy", force_refresh=True)
tools_installed = []
tools_missing = []
if ffmpeg_path:
tools_installed.append("FFmpeg")
else:
tools_missing.append("FFmpeg")
if scrcpy_path:
tools_installed.append("scrcpy")
else:
tools_missing.append("scrcpy")
metadata = {
"ffmpeg": ffmpeg_path is not None,
"scrcpy": scrcpy_path is not None,
"ffmpeg_path": ffmpeg_path,
"scrcpy_path": scrcpy_path,
"tools_installed": tools_installed,
"tools_missing": tools_missing,
}
if ffmpeg_path and scrcpy_path:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Video Recording Toolchain",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Ready (FFmpeg + scrcpy)",
description=f"FFmpeg ({ffmpeg_path}) and scrcpy ({scrcpy_path}) are available. Live stream and high-speed video replays are active.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Toolchain Active",
payload="FFmpeg and scrcpy are installed and active.",
)
],
)
missing_str = " & ".join(tools_missing)
actions = []
if platform.os_type == OSType.WINDOWS:
actions.append(
ProbeAction(
action_type="command",
label="Install via WinGet",
payload="winget install Gyan.FFmpeg Genymobile.scrcpy",
)
)
actions.append(
ProbeAction(
action_type="command",
label="Run PowerShell Setup",
payload="powershell -ExecutionPolicy Bypass -File scripts/install_deps.ps1",
)
)
elif platform.os_type == OSType.MACOS:
actions.append(
ProbeAction(
action_type="command",
label="Install via Homebrew",
payload="brew install ffmpeg scrcpy",
)
)
else:
actions.append(
ProbeAction(
action_type="command",
label="Install on Linux",
payload="sudo apt-get install -y ffmpeg scrcpy",
)
)
actions.append(
ProbeAction(
action_type="command",
label="One-Click Install Script",
payload="bash scripts/install_deps.sh",
)
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Video Recording Toolchain",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary=f"Missing {missing_str}",
description=f"Video toolchain is required for screen streaming and test replay recording. Missing: {missing_str}.",
metadata=metadata,
actions=actions,
)