refactor: unify agent history and harden runtime diagnostics

Preserve user guidance through history compression, stabilize Flash index targets, and retire withdrawn checks without applying stale verdicts.

Improve runtime diagnostics, tool responses, and history rendering. Simplify documentation and comments.

Validation: 2076 tests passed in the full suite; 381 affected tests and 26 frontend tests passed after cleanup. Ruff and the quality ratchet passed.
This commit is contained in:
somew1nd committed 2026-09-09 21:35:40 -07:00
1 parent e983180b5b
commit 5d92939d91
127 files changed
+13738 -1988

No files matched your search

+332 -306
View File
@@ -12,321 +12,155 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""System, environment, and device diagnostics (artemis doctor)."""
"""System, environment, and device diagnostics (artemis doctor).
The command renders the same readiness probes the web console's device wizard
and the ``mobile_diagnose`` MCP tool run (:mod:`artemis.core.diagnostics`), so
the terminal, the browser and the IDE agree on one verdict. Only two rows are
CLI-specific and not engine probes: Node.js/npm and the Showcase UI build.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import json
import platform as sys_platform
import shutil
import subprocess
import sys
from typing import Any
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.table import Table
import typer
from artemis.config import (
parse_llm_config,
settings,
)
from artemis.config.paths import ROOT_DIR
from artemis.core.diagnostics.adb_keys import heal_adb_keys, inspect_adb_keys
from artemis.core.diagnostics.engine import readiness_engine
from artemis.core.diagnostics.readiness import (
Verdict,
adb_keys_corrupted,
base_verdict,
collect_readiness,
sort_by_fix_order,
)
from artemis.core.diagnostics.schema import ProbeResult, ProbeStatus
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.utils.ocr_api import is_ocr_configured
from artemis.runtime.device_lock import DeviceExecutionLock
def doctor_command(
fix: bool = typer.Option(
False,
"--fix",
"-f",
help="Automatically repair fixable environment issues (such as corrupted ADB authentication keys).",
),
) -> None:
"""Run diagnostics to inspect system dependencies, device connectivity, and configuration."""
console = Console()
console.print()
os_name = sys_platform.system()
arch_name = sys_platform.machine()
console.print(
Panel(
f"[bold cyan]☕ Artemis System & Environment Doctor[/bold cyan]\n"
f"[dim]Platform: {os_name} ({arch_name}) | PAL: {platform.os_type.value}[/dim]",
expand=False,
@dataclass(frozen=True)
class ExtraRow:
"""A CLI-only diagnostic row that is not backed by an engine probe."""
key: str
title: str
status: str # "pass" | "missing"
status_markup: str
summary: str
detail: str
@dataclass(frozen=True)
class FixOutcome:
"""Result of one ``--fix`` repair step."""
fix: str
success: bool
message: str
# ---------------------------------------------------------------------------
# Collection
# ---------------------------------------------------------------------------
async def _collect() -> list[ProbeResult]:
report, host = await collect_readiness()
return sort_by_fix_order([*report.probes, host])
async def _apply_fixes(results: list[ProbeResult]) -> list[FixOutcome]:
"""Repair corrupted ADB keys and sweep stale device locks left by dead runners."""
outcomes: list[FixOutcome] = []
if adb_keys_corrupted(results):
result = await readiness_engine.heal_adb_keys()
outcomes.append(
FixOutcome(
fix="heal_adb_keys",
success=bool(result.get("success")),
message=str(result.get("message") or "")
or ("Corrupted RSA keys regenerated." if result.get("success") else "Heal failed."),
)
)
else:
outcomes.append(
FixOutcome(
fix="heal_adb_keys",
success=True,
message="ADB authentication keys are healthy; nothing to repair.",
)
)
removed = await asyncio.to_thread(DeviceExecutionLock.cleanup_stale_locks)
outcomes.append(
FixOutcome(
fix="cleanup_stale_locks",
success=True,
message=(
f"Removed {removed} stale device lock(s)/queue ticket(s)."
if removed
else "No stale device locks or queue tickets found."
),
)
)
return outcomes
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
async def _diagnose(fix: bool) -> tuple[list[ProbeResult], list[FixOutcome]]:
results = await _collect()
fixes: list[FixOutcome] = []
if fix:
fixes = await _apply_fixes(results)
results = await _collect()
return results, fixes
# 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} ({arch_name}, 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 = toolchain.resolve("adb")
if adb_path:
try:
res = subprocess.run(
[adb_path, "version"],
capture_output=True,
text=True,
timeout=5,
encoding="utf-8",
errors="replace",
)
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
hint = (
"Run: winget install Google.PlatformTools"
if platform.os_type == OSType.WINDOWS
else "Run: brew install android-platform-tools"
if platform.os_type == OSType.MACOS
else "Run: sudo apt-get install -y adb"
)
table.add_row(
"Android ADB",
"[bold red]✖ Missing[/bold red]",
f"adb not found in PATH or Android SDK. {hint}",
)
# ---------------------------------------------------------------------------
# CLI-only extra rows (not engine probes)
# ---------------------------------------------------------------------------
# 2b. ADB Authentication RSA Keys Check
key_status = inspect_adb_keys()
if key_status.is_corrupted:
if fix:
heal_result = heal_adb_keys(adb_path=adb_path)
if heal_result.get("success"):
table.add_row(
"ADB Auth Keys",
"[bold green]✔ Repaired[/bold green]",
"Corrupted RSA keys detected and automatically regenerated.",
)
else:
all_passed = False
table.add_row(
"ADB Auth Keys",
"[bold red]✖ Error[/bold red]",
f"Failed to auto-heal keys: {heal_result.get('message')}",
)
else:
all_passed = False
table.add_row(
"ADB Auth Keys",
"[bold red]✖ Corrupted[/bold red]",
f"{key_status.error_reason} Run [bold cyan]artemis doctor --fix[/bold cyan] to auto-repair.",
)
elif key_status.exists:
table.add_row(
"ADB Auth Keys",
"[bold green]✔ OK[/bold green]",
f"{key_status.key_path} ({key_status.key_size_bytes} bytes RSA)",
)
else:
table.add_row(
"ADB Auth Keys",
"[bold cyan]○ Ready[/bold cyan]",
"Keys will be generated automatically on first device connection.",
)
# 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,
encoding="utf-8",
errors="replace",
)
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 (OSError, subprocess.SubprocessError):
# adb probe failed or timed out; report as "no devices detected".
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 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.",
)
# 5. Vision OCR Key Check (Optional)
if is_ocr_configured() and 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 cyan]○ Optional[/bold cyan]",
"OCR_API_KEY is not set. Perception operates in pure XML layout mode.",
)
# 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 = toolchain.resolve("ffmpeg")
scrcpy_path = toolchain.resolve("scrcpy")
if ffmpeg_path:
table.add_row(
"FFmpeg Tools", "[bold green]✔ Installed[/bold green]", f"{ffmpeg_path} (Video ready)"
)
else:
hint = (
"winget install Gyan.FFmpeg"
if platform.os_type == OSType.WINDOWS
else "brew install ffmpeg"
if platform.os_type == OSType.MACOS
else "sudo apt-get install -y ffmpeg"
)
table.add_row(
"FFmpeg Tools",
"[dim]⚪ Optional[/dim]",
f"Not found. ({hint})",
)
if scrcpy_path:
table.add_row(
"Screen Recorder",
"[bold green]✔ Installed[/bold green]",
f"{scrcpy_path} (Live stream ready)",
)
else:
hint = (
"winget install Genymobile.scrcpy"
if platform.os_type == OSType.WINDOWS
else "brew install scrcpy"
if platform.os_type == OSType.MACOS
else "sudo apt-get install -y scrcpy"
)
table.add_row(
"Screen Recorder",
"[dim]⚪ Optional[/dim]",
f"scrcpy not found. ({hint})",
)
# 8. Node.js & npm (for UI compilation)
def _npm_row() -> ExtraRow:
npm_path = shutil.which("npm")
if npm_path:
table.add_row(
"Node.js / npm",
"[bold green]✔ Installed[/bold green]",
f"{npm_path} (Ready to build Showcase UI)",
)
else:
hint = (
"winget install OpenJS.NodeJS.LTS"
if platform.os_type == OSType.WINDOWS
else "brew install node"
if platform.os_type == OSType.MACOS
else "sudo apt-get install -y nodejs npm"
)
table.add_row(
"Node.js / npm",
"[dim]⚪ Optional[/dim]",
f"Not found in PATH. ({hint})",
return ExtraRow(
key="nodejs_npm",
title="Node.js / npm",
status="pass",
status_markup="[bold green]✔ Installed[/bold green]",
summary="Installed",
detail=f"{npm_path} (Ready to build Showcase UI)",
)
hint = (
"winget install OpenJS.NodeJS.LTS"
if platform.os_type == OSType.WINDOWS
else "brew install node"
if platform.os_type == OSType.MACOS
else "sudo apt-get install -y nodejs npm"
)
return ExtraRow(
key="nodejs_npm",
title="Node.js / npm",
status="missing",
status_markup="[dim]⚪ Optional[/dim]",
summary="Not Found",
detail=f"Not found in PATH. ({hint})",
)
# 9. Showcase UI Build Status
def _showcase_row() -> ExtraRow:
from artemis.resources import get_bundled_showcase_dist
base_dist = ROOT_DIR / "apps" / "showcase_ui" / "dist"
@@ -342,22 +176,98 @@ def doctor_command(
if bundled_showcase is not None:
found_showcase = bundled_showcase / "index.html"
if found_showcase:
table.add_row(
"Showcase UI",
"[bold green]✔ Compiled[/bold green]",
f"{found_showcase.parent} (Angular ready)",
)
else:
table.add_row(
"Showcase UI",
"[bold yellow]○ Not Compiled[/bold yellow]",
"Run ./start.sh or artemis ui to auto-compile.",
return ExtraRow(
key="showcase_ui",
title="Showcase UI",
status="pass",
status_markup="[bold green]✔ Compiled[/bold green]",
summary="Compiled",
detail=f"{found_showcase.parent} (Angular ready)",
)
return ExtraRow(
key="showcase_ui",
title="Showcase UI",
status="missing",
status_markup="[bold yellow]○ Not Compiled[/bold yellow]",
summary="Not Compiled",
detail="Run ./start.sh or artemis ui to auto-compile.",
)
console.print(table)
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _status_markup(result: ProbeResult) -> str:
status = result.status
if status is ProbeStatus.PASS:
return "[bold green]✔ OK[/bold green]"
if status is ProbeStatus.SKIPPED:
return "[dim]○ Skipped[/dim]"
summary = escape(result.summary)
if status is ProbeStatus.WARN:
# A warning keeps its summary whether or not it blocks (the host
# probe's warnings degrade the verdict without blocking it).
return f"[bold yellow]⚠ {summary}[/bold yellow]"
if not result.is_blocker:
return "[dim]⚪ Optional[/dim]"
return f"[bold red]✖ {summary}[/bold red]"
def _action_lines(result: ProbeResult) -> list[str]:
"""One plain-text line per remediation; ``&&`` chains split for PowerShell 5.1."""
lines: list[str] = []
for action in result.actions:
payload = action.payload.strip()
if not payload:
continue
if action.action_type == "command":
for part in payload.split("&&"):
if part.strip():
lines.append(f"Run: {part.strip()}")
else:
lines.append(payload)
return lines
def _details_markup(result: ProbeResult) -> str:
# Only summary/description/actions are ever rendered. Probe metadata can
# carry raw credentials (the console prefills its settings form from it)
# and must never reach a terminal or a log.
parts = [escape(result.description)]
if result.status is not ProbeStatus.PASS:
for line in _action_lines(result):
if line.startswith("Run: "):
parts.append(f"[bold cyan]Run:[/bold cyan] {escape(line[len('Run: ') :])}")
else:
parts.append(escape(line))
return "\n".join(parts)
def _render_table(results: list[ProbeResult], extras: list[ExtraRow]) -> Table:
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Component", style="dim", min_width=22, max_width=30)
table.add_column("Status", min_width=12, max_width=28)
table.add_column("Details & Recommendations", overflow="fold")
for result in results:
table.add_row(escape(result.title), _status_markup(result), _details_markup(result))
for extra in extras:
table.add_row(extra.title, extra.status_markup, escape(extra.detail))
return table
def _render_fixes(console: Console, fixes: list[FixOutcome]) -> None:
lines = []
for outcome in fixes:
mark = "[bold green]✔[/bold green]" if outcome.success else "[bold red]✖[/bold red]"
lines.append(f"{mark} {escape(outcome.fix)}: {escape(outcome.message)}")
console.print(Panel("\n".join(lines), title="Repairs (--fix)", expand=False))
console.print()
if all_passed:
def _render_footer(console: Console, verdict: Verdict, results: list[ProbeResult]) -> None:
if verdict == "ready":
console.print(
Panel(
"[bold green]🎉 All system checks passed![/bold green]\n\n"
@@ -367,12 +277,128 @@ def doctor_command(
expand=False,
)
)
else:
return
if verdict == "degraded":
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",
"[bold green]Required checks passed.[/bold green] Optional components are "
"missing; tasks can run, but some features (video replay, OCR) are off.\n\n"
"Run your first automation task now:\n"
' [bold cyan]artemis run "Open Settings and check Battery level"[/bold cyan]',
title="Status: Ready (optional gaps)",
expand=False,
)
)
console.print()
return
failing = [r.title for r in results if r.is_blocker and r.status is not ProbeStatus.PASS]
tips = [
"[bold yellow]💡 Blocking issues:[/bold yellow] " + escape(", ".join(failing)),
"",
"• API keys & device setup: run [bold cyan]artemis init[/bold cyan].",
"• Follow the [bold cyan]Run:[/bold cyan] lines above, then re-run "
"[bold cyan]artemis doctor[/bold cyan].",
"• Using an IDE (Claude Code, Cursor, ...)? Ask it to call "
"[bold cyan]mobile_diagnose[/bold cyan] - it runs these same checks and can "
"apply safe fixes from the assistant.",
]
if adb_keys_corrupted(results):
tips.append(
"• Corrupted ADB keys detected: run [bold cyan]artemis doctor --fix[/bold cyan]."
)
console.print(Panel("\n".join(tips), title="Action Required", expand=False))
def _json_document(
results: list[ProbeResult],
extras: list[ExtraRow],
fixes: list[FixOutcome],
verdict: Verdict,
) -> dict[str, Any]:
# Metadata is intentionally omitted: it may hold raw API keys.
checks = [
{
"id": r.id,
"title": r.title,
"status": r.status.value,
"required": r.is_blocker,
"summary": r.summary,
"detail": r.description,
"fix": []
if r.status is ProbeStatus.PASS
else [
{"type": a.action_type, "label": a.label, "payload": a.payload} for a in r.actions
],
}
for r in results
]
return {
"verdict": verdict,
"checks": checks,
"extras": {
e.key: {
"title": e.title,
"status": e.status,
"summary": e.summary,
"detail": e.detail,
}
for e in extras
},
"fixes": [{"fix": f.fix, "success": f.success, "message": f.message} for f in fixes],
}
# ---------------------------------------------------------------------------
# Command
# ---------------------------------------------------------------------------
def doctor_command(
fix: bool = typer.Option(
False,
"--fix",
"-f",
help=(
"Automatically repair fixable environment issues (corrupted ADB authentication "
"keys, stale device locks left by crashed runners)."
),
),
json_output: bool = typer.Option(
False,
"--json",
help="Print the diagnosis as a JSON document instead of a table.",
),
) -> None:
"""Run diagnostics to inspect system dependencies, device connectivity, and configuration."""
results, fixes = asyncio.run(_diagnose(fix))
extras = [_npm_row(), _showcase_row()]
verdict = base_verdict(results)
if json_output:
typer.echo(
json.dumps(
_json_document(results, extras, fixes, verdict), indent=2, ensure_ascii=False
)
)
else:
console = Console()
console.print()
os_name = sys_platform.system()
arch_name = sys_platform.machine()
console.print(
Panel(
f"[bold cyan]☕ Artemis System & Environment Doctor[/bold cyan]\n"
f"[dim]Platform: {os_name} ({arch_name}) | PAL: {platform.os_type.value}[/dim]",
expand=False,
)
)
if fixes:
_render_fixes(console, fixes)
console.print(_render_table(results, extras))
console.print()
_render_footer(console, verdict, results)
console.print()
if verdict == "blocked":
raise typer.Exit(code=1)
+1
View File
@@ -41,6 +41,7 @@ ARTEMIS_MCP_TOOLS = (
"mobile_manage_task",
"mobile_get_device_state",
"mobile_inspect_trace",
"mobile_diagnose",
)