refactor(doctor): split tool/backend and HERMES_HOME-state checks into doctor_tools.py / doctor_state.py

This commit is contained in:
Teknium
2026-09-02 15:58:40 -07:00
parent 9b05f0df55
commit 2e13aef9ea
3 changed files with 1318 additions and 1243 deletions

File diff suppressed because it is too large Load Diff

592
hermes_cli/doctor_state.py Normal file
View File

@@ -0,0 +1,592 @@
"""HERMES_HOME state checks for hermes doctor: directories, memory files, state.db health, skills hub, memory provider, profiles.
Split out of ``hermes_cli/doctor.py``; every moved name is re-imported there, so
``hermes_cli.doctor.<name>`` keeps resolving (and monkeypatching) as before.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from hermes_cli.doctor_report import (
Finding,
_fail_and_issue,
_section,
check_info,
check_ok,
check_warn,
)
from hermes_cli.sizefmt import format_bytes as _human_bytes
def _honcho_is_configured_for_doctor() -> bool:
"""Return True when Honcho is configured, even if this process has no active session."""
try:
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config()
return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
except Exception:
return False
def _doctor_memory_config(hermes_home: Path | None = None) -> dict:
"""Return the effective memory section used by doctor diagnostics."""
from hermes_cli.doctor import HERMES_HOME
home = hermes_home if hermes_home is not None else HERMES_HOME
try:
from hermes_cli.config import _expand_env_vars, read_user_config_raw
config_path = home / "config.yaml"
if not config_path.exists():
return {}
config = _expand_env_vars(read_user_config_raw(config_path))
try:
from hermes_cli import managed_scope
config = managed_scope.apply_managed_overlay(config)
except Exception:
pass
section = config.get("memory") if isinstance(config, dict) else None
return section if isinstance(section, dict) else {}
except Exception:
return {}
# ── state.db health/stats thresholds (advisory only — module constants,
# deliberately NOT config: doctor warnings are guidance, not policy) ──
STATE_DB_SIZE_WARN_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB logical size
def _render_state_db_stats(stats: dict, holders=None) -> list:
"""Turn a collect_state_db_stats() dict into doctor output lines.
Returns a list of ``(kind, text, detail)`` tuples where kind is one of
'info' / 'warn'. Pure formatting — no I/O — so it is unit-testable
without spawning the doctor CLI. Tolerates None in every field.
"""
lines: list = []
stats = stats or {}
logical = stats.get("logical_size_bytes")
wal = stats.get("wal_size_bytes")
freelist = stats.get("freelist_count")
size_bits = []
if logical is not None:
size_bits.append(f"logical size {_human_bytes(logical)}")
if stats.get("page_count") is not None:
size_bits.append(f"{stats['page_count']:,} pages")
if freelist is not None:
size_bits.append(f"{freelist:,} free")
if wal is not None:
size_bits.append(f"WAL {_human_bytes(wal)}")
if size_bits:
lines.append(("info", "state.db " + ", ".join(size_bits), ""))
row_bits = []
if stats.get("messages") is not None:
row_bits.append(f"{stats['messages']:,} messages")
if stats.get("sessions") is not None:
row_bits.append(f"{stats['sessions']:,} sessions")
if stats.get("journal_mode"):
row_bits.append(f"journal_mode={stats['journal_mode']}")
if holders is not None:
row_bits.append(f"{holders} process(es) holding the DB open")
if row_bits:
lines.append(("info", ", ".join(row_bits), ""))
fts = stats.get("fts_tables")
if fts:
present = [t for t, ok in fts.items() if ok]
lines.append((
"info",
"FTS tables: " + (", ".join(present) if present else "none"),
"",
))
deferral = stats.get("fts_rebuild_deferral")
if isinstance(deferral, dict):
attempts = deferral.get("attempts")
pids = deferral.get("holder_pids") or []
lines.append((
"warn",
f"state.db FTS repair is blocked after {attempts or '?'} "
f"deferral(s) by PID(s) {pids or 'unknown'}",
"(stop the listed processes, then run 'hermes sessions "
"optimize-storage' with the gateway stopped)",
))
# Advisory: oversized database. Suggest auto_prune, and — when the v23
# FTS rebuild is pending OR the DB still carries the legacy inline
# trigram layout (fts_storage_version marker absent) — the offline
# optimize-storage pass that migrates/compacts the FTS indexes.
if logical is not None and logical > STATE_DB_SIZE_WARN_BYTES:
detail = (
"consider enabling sessions.auto_prune in config.yaml "
"to bound growth"
)
legacy_trigram = (
fts is not None
and fts.get("messages_fts_trigram")
and stats.get("fts_storage_version") is None
)
if stats.get("fts_rebuild_pending") or legacy_trigram:
detail += (
"; run 'hermes sessions optimize-storage' offline "
"(with the gateway stopped) to compact FTS storage"
)
lines.append((
"warn",
f"state.db is large ({_human_bytes(logical)})",
f"({detail})",
))
# WAL runaway is deliberately NOT warned here: the pre-existing WAL
# check later in the state.db section already warns above 50 MB and
# offers a checkpoint via --fix; a second warning at a higher threshold
# would only duplicate it.
return lines
def _memory_store_flags(hermes_home: Path) -> tuple:
from tools.memory_tool import get_builtin_memory_store_flags
return get_builtin_memory_store_flags({"memory": _doctor_memory_config(hermes_home)})
def _check_directory_structure(should_fix: bool) -> Finding:
"""HERMES_HOME, expected subdirs, SOUL.md, and the enabled built-in memory files."""
from hermes_cli.doctor import HERMES_HOME, _DHH
f = Finding()
hermes_home = HERMES_HOME
if hermes_home.exists():
check_ok(f"{_DHH} directory exists")
elif should_fix:
hermes_home.mkdir(parents=True, exist_ok=True)
check_ok(f"Created {_DHH} directory")
f.fixed += 1
else:
check_warn(f"{_DHH} not found", "(will be created on first use)")
_memory_enabled, _user_profile_enabled = _memory_store_flags(hermes_home)
# Check expected subdirectories. The built-in file store does not create or
# consume memories/ when both targets are disabled, so stale migration files
# are not an active diagnostic surface.
expected_subdirs = ["cron", "sessions", "logs", "skills"]
if _memory_enabled or _user_profile_enabled:
expected_subdirs.append("memories")
for subdir_name in expected_subdirs:
subdir_path = hermes_home / subdir_name
if subdir_path.exists():
check_ok(f"{_DHH}/{subdir_name}/ exists")
elif should_fix:
subdir_path.mkdir(parents=True, exist_ok=True)
check_ok(f"Created {_DHH}/{subdir_name}/")
f.fixed += 1
else:
check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)")
# Check for SOUL.md persona file
soul_path = hermes_home / "SOUL.md"
if soul_path.exists():
content = soul_path.read_text(encoding="utf-8").strip()
# Check if it's just the template comments (no real content)
lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
if lines:
check_ok(f"{_DHH}/SOUL.md exists (persona configured)")
else:
check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
else:
check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
if should_fix:
soul_path.parent.mkdir(parents=True, exist_ok=True)
soul_path.write_text(
"# Hermes Agent Persona\n\n"
"<!-- Edit this file to customize how Hermes communicates. -->\n\n"
"You are Hermes, a helpful AI assistant.\n",
encoding="utf-8",
)
check_ok(f"Created {_DHH}/SOUL.md with basic template")
f.fixed += 1
# Check only enabled built-in stores. External providers are additive, but
# users can explicitly disable either legacy file target; stale files left
# by a migration must not be presented as active memory usage.
memories_dir = hermes_home / "memories"
if not (_memory_enabled or _user_profile_enabled):
check_info("Built-in memory files disabled by config")
elif memories_dir.exists():
check_ok(f"{_DHH}/memories/ directory exists")
memory_file = memories_dir / "MEMORY.md"
user_file = memories_dir / "USER.md"
if _memory_enabled:
if memory_file.exists():
size = len(memory_file.read_text(encoding="utf-8").strip())
check_ok(f"MEMORY.md exists ({size} chars)")
else:
check_info("MEMORY.md not created yet (will be created when the agent first writes a memory)")
if _user_profile_enabled:
if user_file.exists():
size = len(user_file.read_text(encoding="utf-8").strip())
check_ok(f"USER.md exists ({size} chars)")
else:
check_info("USER.md not created yet (will be created when the agent first writes a memory)")
else:
check_warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
if should_fix:
memories_dir.mkdir(parents=True, exist_ok=True)
check_ok(f"Created {_DHH}/memories/")
f.fixed += 1
return f
def _check_state_db(should_fix: bool) -> Finding:
"""state.db session count, FTS write health, schema repair, stats snapshot, WAL size."""
from hermes_cli.doctor import HERMES_HOME, _DHH
f = Finding()
issues = f.issues
hermes_home = HERMES_HOME
# Check SQLite session store
state_db_path = hermes_home / "state.db"
if state_db_path.exists():
try:
import sqlite3
conn = sqlite3.connect(str(state_db_path))
cursor = conn.execute("SELECT COUNT(*) FROM sessions")
count = cursor.fetchone()[0]
conn.close()
check_ok(f"{_DHH}/state.db exists ({count} sessions)")
# FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds
# even when the FTS index is corrupt and every message write fails
# through the triggers. `_db_opens_cleanly` now drives a rolled-back
# write so this otherwise-silent corruption class is surfaced (and
# repaired in place with --fix).
from hermes_state import _db_opens_cleanly, repair_state_db_schema
_write_reason = _db_opens_cleanly(state_db_path)
if _write_reason is not None:
check_warn(
f"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)",
f"({_write_reason})",
)
if should_fix:
report = repair_state_db_schema(state_db_path)
if report.get("repaired"):
backup_name = (
Path(report["backup_path"]).name
if report.get("backup_path") else "n/a"
)
check_ok(
"Repaired state.db FTS write health",
f"(strategy: {report.get('strategy')}; backup: {backup_name})",
)
f.fixed += 1
else:
check_warn(
"state.db FTS write-health repair did not recover automatically",
f"({report.get('error')}; backup: {report.get('backup_path')})",
)
issues.append(
"state.db FTS write corruption and auto-repair failed — "
"restore from the backup copy beside state.db"
)
else:
issues.append(
"state.db FTS write corruption — run 'hermes doctor --fix' "
"(or 'hermes sessions repair') to rebuild the FTS index"
)
except Exception as e:
from hermes_state import is_malformed_db_error, repair_state_db_schema
if is_malformed_db_error(e):
# sqlite_master itself is malformed (e.g. duplicate
# messages_fts) — every statement fails before it runs, so
# this is NOT a plain FTS-index rebuild. Repair sqlite_master
# in place (backup first; sessions/messages preserved).
check_warn(
f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)",
f"({e})",
)
if should_fix:
report = repair_state_db_schema(state_db_path)
if report.get("repaired"):
try:
conn = sqlite3.connect(str(state_db_path))
count = conn.execute(
"SELECT COUNT(*) FROM sessions"
).fetchone()[0]
conn.close()
except Exception:
count = "?"
backup_name = (
Path(report["backup_path"]).name
if report.get("backup_path") else "n/a"
)
check_ok(
f"Repaired state.db schema ({count} sessions recovered)",
f"(strategy: {report.get('strategy')}; backup: {backup_name})",
)
f.fixed += 1
else:
check_warn(
"state.db schema repair did not recover automatically",
f"({report.get('error')}; backup: {report.get('backup_path')})",
)
issues.append(
"state.db schema malformed and auto-repair failed — "
"restore from the backup copy beside state.db"
)
else:
issues.append(
"state.db schema malformed — run 'hermes doctor --fix' "
"(or 'hermes sessions repair') to recover hidden sessions"
)
else:
check_warn(f"{_DHH}/state.db exists but has issues: {e}")
# Health/stats snapshot (#statedb-visibility): a multi-GB state.db
# with a runaway WAL was previously invisible to every Hermes
# surface. Strictly read-only (mode=ro) so it is safe against a
# live DB held by the gateway; any failure degrades to one info
# line rather than failing doctor.
try:
from hermes_state import collect_state_db_stats, count_db_holders
_db_stats = collect_state_db_stats(state_db_path)
_db_holders = count_db_holders(state_db_path)
for _kind, _text, _detail in _render_state_db_stats(
_db_stats, holders=_db_holders
):
if _kind == "warn":
check_warn(_text, _detail)
if "auto_prune" in _detail:
issues.append(
"state.db is large — enable sessions.auto_prune "
"in config.yaml"
+ (
" and run 'hermes sessions optimize-storage' "
"offline (gateway stopped)"
if "optimize-storage" in _detail else ""
)
)
else:
check_info(_text + (f" {_detail}" if _detail else ""))
except Exception as _stats_exc:
check_info(f"state.db stats unavailable ({_stats_exc})")
else:
check_info(f"{_DHH}/state.db not created yet (will be created on first session)")
# Check WAL file size (unbounded growth indicates missed checkpoints)
wal_path = hermes_home / "state.db-wal"
if wal_path.exists():
try:
wal_size = wal_path.stat().st_size
if wal_size > 50 * 1024 * 1024: # 50 MB
check_warn(
f"WAL file is large ({wal_size // (1024*1024)} MB)",
"(may indicate missed checkpoints)"
)
if should_fix:
import sqlite3
conn = sqlite3.connect(str(state_db_path))
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
conn.close()
new_size = wal_path.stat().st_size if wal_path.exists() else 0
check_ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)")
f.fixed += 1
else:
issues.append("Large WAL file — run 'hermes doctor --fix' to checkpoint")
elif wal_size > 10 * 1024 * 1024: # 10 MB
check_info(f"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)")
except Exception:
pass
return f
def _check_skills_hub(should_fix: bool) -> Finding:
from hermes_cli.doctor import HERMES_HOME, _DHH
f = Finding()
hub_dir = HERMES_HOME / "skills" / ".hub"
if hub_dir.exists():
check_ok("Skills Hub directory exists")
lock_file = hub_dir / "lock.json"
if lock_file.exists():
try:
import json
lock_data = json.loads(lock_file.read_text(encoding="utf-8"))
count = len(lock_data.get("installed", {}))
check_ok(f"Lock file OK ({count} hub-installed skill(s))")
except Exception:
check_warn("Lock file", "(corrupted or unreadable)")
quarantine = hub_dir / "quarantine"
q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0
if q_count > 0:
check_warn(f"{q_count} skill(s) in quarantine", "(pending review)")
else:
check_warn("Skills Hub directory not initialized", "(run: hermes skills list)")
from hermes_cli.config import get_env_value
def _gh_authenticated() -> bool:
"""Check if gh CLI is authenticated via token file or device flow."""
try:
result = subprocess.run(
["gh", "auth", "status", "--json", "authenticated"],
capture_output=True, timeout=10,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
if github_token:
check_ok("GitHub token configured (authenticated API access)")
elif _gh_authenticated():
check_ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
else:
check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
return f
def _check_memory_provider(should_fix: bool) -> Finding:
from hermes_cli.doctor import HERMES_HOME
f = Finding()
issues = f.issues
_active_memory_provider = _doctor_memory_config(HERMES_HOME).get("provider", "")
if not _active_memory_provider:
check_ok("Built-in memory active", "(no external provider configured — this is fine)")
elif _active_memory_provider == "honcho":
try:
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
hcfg = HonchoClientConfig.from_global_config()
_honcho_cfg_path = resolve_config_path()
if not _honcho_cfg_path.exists():
# Config file missing — but env var fallback may have resolved it.
# Only warn if the config didn't actually resolve from env vars.
if hcfg.api_key or hcfg.base_url:
check_ok(
"Honcho configured via environment variables",
f"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var",
)
else:
check_warn("Honcho config not found", "run: hermes memory setup")
elif not hcfg.enabled:
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
elif not (hcfg.api_key or hcfg.base_url):
_fail_and_issue(
"Honcho API key or base URL not set",
"run: hermes memory setup",
"No Honcho API key — run 'hermes memory setup'",
issues,
)
else:
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
reset_honcho_client()
try:
get_honcho_client(hcfg)
check_ok(
"Honcho connected",
f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}",
)
except Exception as _e:
_fail_and_issue("Honcho connection failed", str(_e), f"Honcho unreachable: {_e}", issues)
except ImportError:
_fail_and_issue(
"honcho-ai not installed",
"pip install honcho-ai",
"Honcho is set as memory provider but honcho-ai is not installed",
issues,
)
except Exception as _e:
check_warn("Honcho check failed", str(_e))
elif _active_memory_provider == "mem0":
try:
from plugins.memory.mem0 import _load_config as _load_mem0_config
mem0_cfg = _load_mem0_config()
mem0_key = mem0_cfg.get("api_key", "")
if mem0_key:
check_ok("Mem0 API key configured")
check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}")
else:
_fail_and_issue(
"Mem0 API key not set",
"(set MEM0_API_KEY in .env or run hermes memory setup)",
"Mem0 is set as memory provider but API key is missing",
issues,
)
except ImportError:
_fail_and_issue(
"Mem0 plugin not loadable",
"pip install mem0ai",
"Mem0 is set as memory provider but mem0ai is not installed",
issues,
)
except Exception as _e:
check_warn("Mem0 check failed", str(_e))
else:
# Generic check for other memory providers (openviking, hindsight, etc.)
try:
from plugins.memory import load_memory_provider
_provider = load_memory_provider(_active_memory_provider)
if _provider and _provider.is_available():
check_ok(f"{_active_memory_provider} provider active")
elif _provider:
check_warn(f"{_active_memory_provider} configured but not available", "run: hermes memory status")
else:
check_warn(f"{_active_memory_provider} plugin not found", "run: hermes memory setup")
except Exception as _e:
check_warn(f"{_active_memory_provider} check failed", str(_e))
return f
def _check_profiles(should_fix: bool) -> Finding:
f = Finding()
try:
from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
import re as _re
named_profiles = [p for p in list_profiles() if not p.is_default]
if named_profiles:
_section("Profiles")
check_ok(f"{len(named_profiles)} profile(s) found")
wrapper_dir = _get_wrapper_dir()
for p in named_profiles:
parts = []
if p.gateway_running:
parts.append("gateway running")
if p.model:
parts.append(p.model[:30])
if not (p.path / "config.yaml").exists():
parts.append("⚠ missing config")
if not (p.path / ".env").exists():
parts.append("no .env")
wrapper = wrapper_dir / p.name
if not wrapper.exists():
parts.append("no alias")
status = ", ".join(parts) if parts else "configured"
check_ok(f" {p.name}: {status}")
# Check for orphan wrappers
if wrapper_dir.is_dir():
for wrapper in wrapper_dir.iterdir():
if not wrapper.is_file():
continue
try:
content = wrapper.read_text(encoding="utf-8")
if "hermes -p" in content:
_m = _re.search(r"hermes -p (\S+)", content)
if _m and not profile_exists(_m.group(1)):
check_warn(f"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists")
except Exception:
pass
except ImportError:
pass
except Exception:
pass
return f

690
hermes_cli/doctor_tools.py Normal file
View File

@@ -0,0 +1,690 @@
"""External-tool checks for hermes doctor: terminal backends, git/rg, Node + agent-browser, npm audit, tool availability.
Split out of ``hermes_cli/doctor.py``; every moved name is re-imported there, so
``hermes_cli.doctor.<name>`` keeps resolving (and monkeypatching) as before.
"""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import sys
from hermes_cli.doctor_platform import _system_package_install_cmd
from hermes_cli.doctor_report import Finding, _fail_and_issue, check_info, check_ok, check_warn
from hermes_cli.vercel_auth import describe_vercel_auth
from hermes_constants import agent_browser_runnable, is_termux as _is_termux
def _safe_which(cmd: str) -> str | None:
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
try:
return shutil.which(cmd)
except Exception:
return None
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
steps: list[str] = []
step = 1
if not node_installed:
steps.append(f"{step}) pkg install nodejs")
step += 1
steps.append(f"{step}) npm install -g agent-browser")
steps.append(f"{step + 1}) agent-browser install")
return steps
def _termux_install_all_fallback_notes() -> list[str]:
return [
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).",
"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).",
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
]
def _is_kanban_worker_env_gate(item: dict) -> bool:
"""Return True when Kanban is unavailable only because this is not a worker process."""
if item.get("name") != "kanban":
return False
if os.environ.get("HERMES_KANBAN_TASK"):
return False
tools = item.get("tools") or []
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
def _doctor_tool_availability_detail(toolset: str) -> str:
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
return ""
def _doctor_web_capability_rows() -> list[tuple[str, str, str]]:
"""Return doctor rows for web search/extract provider readiness (#78412).
Each row is ``(status, label, detail)`` where *status* is ``ok`` or ``warn``.
Uses the same active-provider resolvers as the tools, but reports readiness
from ``is_available()`` so an explicitly selected but unconfigured backend
does not look healthy.
"""
rows: list[tuple[str, str, str]] = []
try:
from agent.web_search_registry import (
get_active_extract_provider,
get_active_search_provider,
)
from tools.web_tools import _ensure_web_plugins_loaded, _provider_is_ready
# Doctor runs in a fresh process — bundled web providers register
# during plugin discovery, which nothing has triggered yet here.
# Without this the registry is empty and every row reads
# "no provider selected or registered" (idempotent, cheap on rerun).
_ensure_web_plugins_loaded()
except Exception:
return rows
for capability, getter in (
("web search", get_active_search_provider),
("web extract", get_active_extract_provider),
):
try:
provider = getter()
except Exception:
provider = None
if provider is None:
rows.append(
(
"warn",
capability,
"(no provider selected or registered)",
)
)
continue
name = getattr(provider, "name", None) or type(provider).__name__
if _provider_is_ready(provider):
rows.append(("ok", capability, f"({name})"))
else:
rows.append(
(
"warn",
capability,
f"({name} selected; provider not configured)",
)
)
return rows
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
"""Adjust runtime-gated tool availability for doctor diagnostics."""
from hermes_cli.doctor import _honcho_is_configured_for_doctor
updated_available = list(available)
updated_unavailable = []
for item in unavailable:
name = item.get("name")
if _is_kanban_worker_env_gate(item):
if "kanban" not in updated_available:
updated_available.append("kanban")
continue
if name == "honcho" and _honcho_is_configured_for_doctor():
if "honcho" not in updated_available:
updated_available.append("honcho")
continue
updated_unavailable.append(item)
return updated_available, updated_unavailable
def _enabled_cli_toolsets_for_doctor() -> set[str] | None:
"""Return toolsets enabled for the CLI, or None if config resolution fails."""
try:
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, "cli")}
except Exception:
return None
def _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:
"""Filter unavailable API-key toolsets to those enabled for the CLI."""
from hermes_cli.doctor import _enabled_cli_toolsets_for_doctor
api_key_unavailable = [
item for item in unavailable
if item.get("missing_vars") or item.get("env_vars")
]
enabled_toolsets = _enabled_cli_toolsets_for_doctor()
if enabled_toolsets is None:
return api_key_unavailable
return [
item for item in api_key_unavailable
if str(item.get("name") or "") in enabled_toolsets
]
def _check_git_and_rg(should_fix: bool) -> Finding:
f = Finding()
# Git
if _safe_which("git"):
check_ok("git")
else:
check_warn("git not found", "(optional)")
# ripgrep (optional, for faster file search)
if _safe_which("rg"):
check_ok("ripgrep (rg)", "(faster file search)")
else:
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}")
return f
def _check_terminal_backend(should_fix: bool) -> Finding:
"""Docker/SSH/Daytona/Vercel/plugin terminal backends, gated on TERMINAL_ENV."""
f = Finding()
issues = f.issues
# Docker (optional)
terminal_env = os.getenv("TERMINAL_ENV", "local")
try:
from hermes_constants import is_container as _is_container
running_in_container = _is_container()
except Exception:
running_in_container = False
if running_in_container:
# Inside our container the Docker terminal backend is not
# configured by default (Docker-in-Docker isn't set up); the
# local backend is the intended one. Skip the noisy "docker
# not found" warning. If the user has explicitly chosen
# TERMINAL_ENV=docker inside the container they likely mounted
# /var/run/docker.sock, so fall through to the normal check.
if terminal_env != "docker":
check_info(
"Running inside a container — using local terminal backend "
"(docker-in-docker is not configured by default)"
)
# Skip to next section; Docker isn't relevant here.
terminal_env = "local"
if terminal_env == "docker":
if _safe_which("docker"):
# Check if docker daemon is running
try:
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
except subprocess.TimeoutExpired:
result = None
if result is not None and result.returncode == 0:
check_ok("docker", "(daemon running)")
else:
_fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues)
else:
_fail_and_issue(
"docker not found",
"(required for TERMINAL_ENV=docker)",
"Install Docker or change TERMINAL_ENV",
issues,
)
elif _safe_which("docker"):
check_ok("docker", "(optional)")
elif _is_termux():
check_info("Docker backend is not available inside Termux (expected on Android)")
elif running_in_container:
pass # already explained above
else:
check_warn("docker not found", "(optional)")
# SSH (if using ssh backend)
if terminal_env == "ssh":
ssh_host = os.getenv("TERMINAL_SSH_HOST")
if ssh_host:
ssh_user = os.getenv("TERMINAL_SSH_USER")
ssh_port = os.getenv("TERMINAL_SSH_PORT")
ssh_key = os.getenv("TERMINAL_SSH_KEY")
target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
if ssh_port:
cmd += ["-p", ssh_port]
if ssh_key:
cmd += ["-i", os.path.expanduser(ssh_key)]
cmd += [target, "echo ok"]
# Try to connect
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=15
)
except subprocess.TimeoutExpired:
result = None
if result is not None and result.returncode == 0:
check_ok(f"SSH connection to {ssh_host}")
else:
_fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues)
else:
_fail_and_issue(
"TERMINAL_SSH_HOST not set",
"(required for TERMINAL_ENV=ssh)",
"Set TERMINAL_SSH_HOST in .env",
issues,
)
# Daytona (if using daytona backend)
if terminal_env == "daytona":
daytona_key = os.getenv("DAYTONA_API_KEY")
if daytona_key:
check_ok("Daytona API key", "(configured)")
else:
_fail_and_issue(
"DAYTONA_API_KEY not set",
"(required for TERMINAL_ENV=daytona)",
"Set DAYTONA_API_KEY environment variable",
issues,
)
try:
from daytona import Daytona # noqa: F401 — SDK presence check
check_ok("daytona SDK", "(installed)")
except ImportError:
_fail_and_issue(
"daytona SDK not installed",
"(pip install daytona)",
"Install daytona SDK: pip install daytona",
issues,
)
# Vercel Sandbox (if using vercel_sandbox backend)
if terminal_env == "vercel_sandbox":
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
if runtime in _SUPPORTED_VERCEL_RUNTIMES:
check_ok("Vercel runtime", f"({runtime})")
else:
supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
_fail_and_issue(
"Vercel runtime unsupported",
f"({runtime}; use {supported})",
f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}",
issues,
)
disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip()
if disk in {"", "0", "51200"}:
check_ok("Vercel disk setting", "(uses platform default)")
else:
_fail_and_issue(
"Vercel custom disk unsupported",
"(reset terminal.container_disk to 51200)",
"Vercel Sandbox does not support custom container_disk; use the shared default 51200",
issues,
)
if importlib.util.find_spec("vercel") is not None:
check_ok("vercel SDK", "(installed)")
else:
_fail_and_issue(
"vercel SDK not installed",
"(pip install 'hermes-agent[vercel]')",
"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'",
issues,
)
auth_status = describe_vercel_auth()
if auth_status.ok:
check_ok("Vercel auth", f"({auth_status.label})")
elif auth_status.label.startswith("partial"):
_fail_and_issue(
"Vercel auth incomplete",
f"({auth_status.label})",
"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together",
issues,
)
else:
_fail_and_issue(
"Vercel auth not configured",
f"({auth_status.label})",
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID",
issues,
)
for line in auth_status.detail_lines:
check_info(f"Vercel auth {line}")
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}
if persistent:
check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
else:
check_info("Vercel persistence: ephemeral filesystem")
# Plugin-registered terminal backends (if one is the active backend)
if terminal_env not in {
"local", "docker", "singularity", "modal", "managed_modal",
"daytona", "vercel_sandbox", "ssh",
}:
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
from agent.terminal_env_registry import get_provider
_provider = get_provider(terminal_env)
except Exception:
_provider = None
if _provider is None:
_fail_and_issue(
f"Unknown terminal backend '{terminal_env}'",
"(no built-in or plugin backend by that name)",
"Fix terminal.backend in config.yaml, or install/enable the plugin that provides it",
issues,
)
else:
for _ok, _label, _detail in _provider.doctor_checks():
if _ok:
check_ok(_label, _detail)
else:
_fail_and_issue(_label, _detail, _detail.strip("()"), issues)
return f
def _check_node_and_browser(should_fix: bool) -> Finding:
"""Node.js, agent-browser resolution, Playwright Chromium, Lightpanda engine."""
from hermes_cli.doctor import PROJECT_ROOT
f = Finding()
# Node.js + agent-browser (for browser automation tools)
if _safe_which("node"):
check_ok("Node.js")
# agent-browser is no longer a root package.json dependency (#43564)
# — it resolves lazily via npx (or a global/Hermes-managed install)
# at first use. Mirror tools.browser_tool._find_agent_browser's own
# resolution cascade here so doctor can't diverge from what browser
# tools will actually find; validate=False keeps this a cheap
# existence check with no subprocess spawn or install side effects.
agent_browser_ok = False
try:
from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel
_resolved_ab = _find_agent_browser(validate=False)
except Exception:
_resolved_ab = None
if _resolved_ab and _is_npx_agent_browser_sentinel(_resolved_ab):
check_ok("agent-browser", "(resolves via npx on first use)")
agent_browser_ok = True
if should_fix:
# Doctor can't tell from here whether npx's cache already
# has agent-browser warm — just fire the same warm-up
# `hermes update` does, so a session's first browser call
# doesn't pay the registry fetch either way.
from tools.browser_tool import warm_agent_browser_npx_cache
if warm_agent_browser_npx_cache():
check_info(" Warmed npx cache for agent-browser")
else:
check_info(" Could not warm npx cache (offline or npx unavailable)")
elif _resolved_ab and agent_browser_runnable(_resolved_ab):
check_ok("agent-browser", "(browser automation)")
agent_browser_ok = True
elif _resolved_ab:
# Found on PATH but won't run — almost always a dangling global
# symlink left behind by agent-browser's npm postinstall after a
# `hermes update` wiped node_modules (issue #48521).
check_warn(
"agent-browser found but not runnable",
f"(broken symlink at {_resolved_ab}? run: npx agent-browser --version)",
)
elif _is_termux():
check_info("agent-browser is not installed (expected in the tested Termux path)")
check_info("Install it manually later with: npm install -g agent-browser && agent-browser install")
check_info("Termux browser setup:")
for step in _termux_browser_setup_steps(node_installed=True):
check_info(step)
else:
check_warn("agent-browser not installed", "(requires npm/npx on PATH)")
# Chromium presence — the browser tools silently fail to register when
# agent-browser is found but no Playwright-managed Chromium is on disk
# (tools/browser_tool.py::check_browser_requirements filters them out
# before the agent ever sees them). Reuse the exact predicate it uses
# so the two checks cannot diverge. Skip on Termux (not a tested
# path).
if agent_browser_ok and not _is_termux():
try:
# Lazy import: browser_tool is a ~150KB module we don't want
# to eagerly load in every `hermes doctor` invocation.
from tools.browser_tool import (
_chromium_installed,
_is_camofox_mode,
_get_cloud_provider,
_get_cdp_override_raw,
_using_lightpanda_engine,
)
except Exception:
# If browser_tool can't even import, that's a separate bug
# surfaced elsewhere; don't crash doctor.
pass
else:
# Only warn about Chromium if the installed engine actually
# requires it: Camofox, CDP override, a cloud provider, or
# Lightpanda all bypass the local Chromium requirement.
skip_chromium_check = (
_is_camofox_mode()
or bool(_get_cdp_override_raw())
or _get_cloud_provider() is not None
or _using_lightpanda_engine()
)
if not skip_chromium_check:
if _chromium_installed():
check_ok("Playwright Chromium", "(browser engine)")
else:
check_warn(
"Playwright Chromium not installed",
"(browser_* tools will be hidden from the agent)",
)
if sys.platform == "win32":
check_info(
f"Install with: cd {PROJECT_ROOT} && "
"npx playwright install chromium"
)
else:
check_info(
f"Install with: cd {PROJECT_ROOT} && "
"npx playwright install --with-deps chromium"
)
elif _is_termux():
check_info("Node.js not found (browser tools are optional in the tested Termux path)")
check_info("Install Node.js on Termux with: pkg install nodejs")
check_info("Termux browser setup:")
for step in _termux_browser_setup_steps(node_installed=False):
check_info(step)
else:
check_warn("Node.js not found", "(optional, needed for browser tools)")
# Lightpanda engine (browser.engine / AGENT_BROWSER_ENGINE). Independent
# of Node: Browser Use mode spawns ``lightpanda serve`` itself.
try:
from tools.browser_tool import _using_lightpanda_engine, lightpanda_engine_status
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
except Exception:
pass
else:
# _using_lightpanda_engine() is a cached config read — a failure
# there would be exceptional, not something to silently hide.
if _using_lightpanda_engine():
try:
_lp_used, _lp_reason = lightpanda_engine_status()
except Exception as e:
_lp_used, _lp_reason = False, f"status check failed: {e}"
if not _lp_used:
check_warn("browser.engine=lightpanda is shadowed", f"({_lp_reason})")
check_info(
"Fix: pick Lightpanda in `hermes tools` → Browser Automation, "
"or set browser.engine: auto"
)
elif find_lightpanda_binary():
check_ok("Lightpanda", f"({_lp_reason})")
else:
check_warn(
"Lightpanda selected but binary not found",
"(browser tools will fail until it is installed)",
)
check_info(LIGHTPANDA_INSTALL_HINT)
return f
def _check_npm_audit(should_fix: bool) -> Finding:
"""npm audit per Node package tree (root, web/ui-tui workspaces, WhatsApp bridge)."""
from hermes_cli.doctor import PROJECT_ROOT
f = Finding()
issues = f.issues
# npm audit for all Node.js packages
_npm_bin = _safe_which("npm")
if _npm_bin:
# Each entry: (cwd, label, extra_audit_args)
# PROJECT_ROOT is audited with --workspaces=false so that the apps/*
# glob (which pulls in Electron, node-pty, etc.) is never resolved
# for a routine security check. The web and ui-tui workspaces are
# audited separately via --workspace flags. See #38772.
# The WhatsApp bridge may live under a writable HERMES_HOME mirror
# instead of the (possibly read-only) install tree in Docker — resolve
# it through the shared helper so we audit the dir that actually holds
# node_modules. See #49561.
try:
from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir
_whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()
except Exception:
_whatsapp_bridge_dir = PROJECT_ROOT / "scripts" / "whatsapp-bridge"
npm_audit_targets = [
(PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]),
(PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
(_whatsapp_bridge_dir, "WhatsApp bridge", []),
]
for npm_dir, label, audit_extra in npm_audit_targets:
# For workspace-scoped audits run from PROJECT_ROOT the
# node_modules check must use the workspace root; standalone dirs
# (whatsapp-bridge) check their own node_modules.
check_dir = PROJECT_ROOT if audit_extra else npm_dir
if not (check_dir / "node_modules").exists():
continue
try:
# Use resolved absolute path so Windows can execute
# npm.cmd (CreateProcessW can't run bare .cmd names).
audit_result = subprocess.run(
[_npm_bin, "audit", "--json", *audit_extra],
cwd=str(npm_dir),
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30,
)
import json as _json
audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}
vuln_count = audit_data.get("metadata", {}).get("vulnerabilities", {})
critical = vuln_count.get("critical", 0)
high = vuln_count.get("high", 0)
moderate = vuln_count.get("moderate", 0)
total = critical + high + moderate
# Determine a scoped fix command for the remediation hint.
if audit_extra and audit_extra[0] == "--workspace":
# Detection (`npm audit --workspace <name>`) is read-only and
# safe, but `npm audit fix --workspace <name>` crashes on
# current npm with "Cannot read properties of null (reading
# 'edgesOut')" — an arborist bug with workspace-filtered
# audit fix. The root-level `npm audit fix` can crash on the
# same tree with "isDescendantOf", so do not hand the user a
# manual fix command for these build-tool advisories.
fix_cmd = None
elif audit_extra == ["--workspaces=false"]:
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
else:
fix_cmd = f"cd {npm_dir} && npm audit fix"
if total == 0:
check_ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
if fix_cmd:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}"
)
else:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — "
"build-tool advisory; clears via lockfile bump"
)
check_warn(
f"{label} deps",
f"({vuln_detail})"
)
if audit_extra and audit_extra[0] == "--workspace":
# The web/ui-tui workspace advisories are in build-time
# tooling (esbuild/vite, etc.), not runtime code that ships
# to users. Manual npm remediation may error with a known
# arborist crash (edgesOut / isDescendantOf) on this monorepo
# tree — in that case it is an npm bug, not a Hermes one.
check_info(
" ^ build-time tooling (not runtime); if manual npm remediation "
"errors with an arborist crash it's a known npm bug — clears "
"via a lockfile bump"
)
issues.append(
f"{label} has {total} npm "
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
)
else:
check_ok(
f"{label} deps",
f"({moderate} moderate "
f"{'vulnerability' if moderate == 1 else 'vulnerabilities'})",
)
except Exception:
pass
if _is_termux():
check_info("Termux compatibility fallbacks:")
for note in _termux_install_all_fallback_notes():
check_info(note)
return f
def _check_tool_availability(should_fix: bool) -> Finding:
from hermes_cli.doctor import PROJECT_ROOT, _doctor_web_capability_rows
f = Finding()
issues = f.issues
try:
# Add project root to path for imports
sys.path.insert(0, str(PROJECT_ROOT))
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
available, unavailable = check_tool_availability()
available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)
# Web is split into search/extract readiness rows so an explicitly
# selected but unconfigured backend cannot look healthy (#78412).
web_rows = []
if "web" in available or any(item.get("name") == "web" for item in unavailable):
web_rows = _doctor_web_capability_rows()
if web_rows:
available = [tid for tid in available if tid != "web"]
unavailable = [item for item in unavailable if item.get("name") != "web"]
for tid in available:
info = TOOLSET_REQUIREMENTS.get(tid, {})
check_ok(info.get("name", tid), _doctor_tool_availability_detail(tid))
for status, label, detail in web_rows:
if status == "ok":
check_ok(label, detail)
else:
check_warn(label, detail)
for item in unavailable:
env_vars = item.get("missing_vars") or item.get("env_vars") or []
if env_vars:
vars_str = ", ".join(env_vars)
check_warn(item["name"], f"(missing {vars_str})")
else:
check_warn(item["name"], "(system dependency not met)")
# Count missing API-key requirements only for toolsets enabled in the
# current CLI platform. Default-off or explicitly disabled toolsets may
# still show warnings above, but should not pollute the final summary.
api_disabled = _missing_api_key_toolsets_for_summary(unavailable)
web_not_ready = any(status != "ok" for status, _, _ in web_rows)
if api_disabled or web_not_ready:
issues.append("Run 'hermes setup' to configure missing API keys for full tool access")
except Exception as e:
check_warn("Could not check tool availability", f"({e})")
return f