# Conflicts: # .gitignore # Dockerfile # agent/onboarding.py # apps/desktop/electron/main.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/src/components/model-picker.test.tsx # apps/desktop/src/store/updates.ts # apps/desktop/vite.config.ts # datagen-config-examples/run_browser_tasks.sh # docs/rca-ssl-cacert-post-git-pull.md # gateway/run.py # hermes_cli/backup.py # hermes_cli/credential_lifecycle.py # hermes_cli/dashboard_procs.py # hermes_cli/doctor_state.py # hermes_cli/env_loader.py # hermes_cli/gateway_windows.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/psutil_android.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_windows.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_server_config.py # hermes_cli/web_server_cron.py # plugins/memory/hindsight/__init__.py # plugins/memory/holographic/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/mem0/__init__.py # plugins/platforms/google_chat/oauth.py # plugins/platforms/photon/adapter.py # scripts/ci/list_os_marked_tests.py # scripts/run_tests.sh # tests/agent/test_compression_stall_fallback.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/gateway/test_google_chat_oauth_dependencies.py # tests/hermes_cli/conftest.py # tests/hermes_cli/test_cli_init.py # tests/hermes_cli/test_gateway_migrate_multiplex.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_relaunch.py # tests/hermes_cli/test_update_check.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_worktree_gc.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_autostash_conflict_recovery.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_commit_pin_rollback.py # tests/scripts/install/test_install_diverged_update.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_macos_launcher.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_ps1_ascii_only.py # tests/scripts/install/test_install_ps1_browser_install.py # tests/scripts/install/test_install_ps1_managed_node_swap.py # tests/scripts/install/test_install_ps1_native_stderr_eap.py # tests/scripts/install/test_install_ps1_node_path_for_npm.py # tests/scripts/install/test_install_ps1_python_fallback_venv.py # tests/scripts/install/test_install_ps1_resolver_strictmode.py # tests/scripts/install/test_install_ps1_uv_install_fallback.py # tests/scripts/install/test_install_ps1_uv_powershell_host.py # tests/scripts/install/test_install_ps1_venv_process_tree.py # tests/scripts/install/test_install_ps1_venv_recreate_safety.py # tests/scripts/install/test_install_ps1_venv_rename_abort.py # tests/scripts/install/test_install_ps1_venv_transaction_boundary.py # tests/scripts/install/test_install_ps1_web_server_syntax_probe.py # tests/scripts/install/test_install_scripts_computer_use.py # tests/scripts/install/test_install_sh_acp_launcher.py # tests/scripts/install/test_install_sh_bootstrap_marker.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_install_method_stamp.py # tests/scripts/install/test_install_sh_node_deps_failure.py # tests/scripts/install/test_install_sh_node_deps_workspaces.py # tests/scripts/install/test_install_sh_node_global_prefix.py # tests/scripts/install/test_install_sh_node_npm_check.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_node_probe.py # tests/scripts/install/test_install_sh_node_tarball_without_xz.py # tests/scripts/install/test_install_sh_pythonpath_sanitization.py # tests/scripts/install/test_install_sh_reuse_supported_python.py # tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py # tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_termux_network_prereqs.py # tests/scripts/install/test_install_sh_termux_python_bounds.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_run_tests_parallel.py # tests/test_managed_runtime_resolution.py # tests/test_project_metadata.py # tests/tools/test_browser_use_cli.py # tests/tools/test_tts_pythonpath_fallback.py # tests/tui_gateway/test_hosted_room_driver_runtime.py # tests/tui_gateway/test_tui_gateway_server.py # tools/lazy_deps.py # tools/voice_mode.py # uv.lock # website/docs/developer-guide/macos-bundle-updates.md # website/docs/developer-guide/pm-audit-status.md # website/docs/developer-guide/shared-bundle-builds.md # website/docs/developer-guide/source-update-completion.md # website/docs/developer-guide/stable-releases.md
214 lines
8.9 KiB
Python
214 lines
8.9 KiB
Python
"""``hermes doctor --live`` — opt-in bounded real-call tool-backend probes.
|
|
|
|
Opt-in only: these probes make real (cheap, metadata/read-only) network calls and may spend a
|
|
trivial amount of quota. They run ONLY when the user passes ``hermes doctor --live``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Callable, List, Optional
|
|
|
|
from hermes_cli.browser_runtime import chromium_executable
|
|
from hermes_cli.doctor import _section, check_info
|
|
from hermes_cli.doctor_report import check_fail, check_ok, check_warn
|
|
|
|
DEFAULT_PROBE_TIMEOUT = 10.0
|
|
|
|
# Metadata-only endpoints (none spend generation credits): name -> (url, env var, auth scheme).
|
|
_KEYED_PROBES = {
|
|
"Firecrawl": ("https://api.firecrawl.dev/v2/team/credit-usage", "FIRECRAWL_API_KEY", "Bearer"),
|
|
"FAL": ("https://fal.ai/api/models?page=1", "FAL_KEY", "Key"),
|
|
}
|
|
# TTS/STT providers that never touch the network (nothing to probe).
|
|
_LOCAL_AUDIO_PROVIDERS = {"", "local", "edge", "neutts", "kittentts", "piper"}
|
|
_AUDIO_PROBES = {
|
|
"openai": ("https://api.openai.com/v1/models", "OPENAI_API_KEY", "Bearer"),
|
|
"groq": ("https://api.groq.com/openai/v1/models", "GROQ_API_KEY", "Bearer"),
|
|
"elevenlabs": ("https://api.elevenlabs.io/v1/voices", "ELEVENLABS_API_KEY", "xi"),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ProbeResult:
|
|
"""Outcome of one backend probe."""
|
|
|
|
name: str
|
|
status: str # "pass" | "warn" | "fail" | "skip"
|
|
detail: str = ""
|
|
|
|
|
|
# ── Small seams (monkeypatchable in tests, and single points of control) ──
|
|
|
|
def _http_get(url: str, headers: Optional[dict] = None, timeout: Optional[float] = None):
|
|
"""Single HTTP GET seam for all metadata probes."""
|
|
import httpx
|
|
return httpx.get(url, headers=headers or {}, timeout=timeout)
|
|
|
|
|
|
def _browser_available() -> bool:
|
|
"""Is the local browser automation backend (agent-browser) installed?"""
|
|
try:
|
|
from tools.browser_tool_install import _find_agent_browser
|
|
return bool(_find_agent_browser(validate=False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _launch_browser_probe(timeout: float) -> tuple:
|
|
"""Launch a browser, open about:blank, close. Returns (ok, detail). Uses Playwright directly (what
|
|
agent-browser drives underneath) so the probe owns the full lifecycle and always cleans up."""
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError:
|
|
return (False, "playwright not installed")
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(
|
|
channel="chromium", executable_path=chromium_executable(),
|
|
headless=True, timeout=timeout * 1000,
|
|
)
|
|
try:
|
|
browser.new_page().goto("about:blank", timeout=timeout * 1000)
|
|
finally:
|
|
browser.close()
|
|
return (True, "launched + about:blank + closed")
|
|
|
|
|
|
def _probe_mcp_server(name: str, config: dict, timeout: float):
|
|
"""initialize + tools/list against one configured MCP server."""
|
|
from hermes_cli.mcp_config import _probe_single_server
|
|
return _probe_single_server(name, config, connect_timeout=timeout)
|
|
|
|
|
|
# ── Per-backend probes. Each returns a ProbeResult; _run_one's catch-all handles crashes. ──
|
|
|
|
def _classify_http(name: str, resp, key_hint: str) -> ProbeResult:
|
|
code = getattr(resp, "status_code", None)
|
|
if code is not None and 200 <= code < 300:
|
|
return ProbeResult(name, "pass", f"(HTTP {code})")
|
|
return ProbeResult(name, "fail", f"(HTTP {code} — check {key_hint})" if code in (401, 403) else f"(HTTP {code})")
|
|
|
|
|
|
def _keyed_probe(name: str, url: str, env_var: str, scheme: str, timeout: float) -> ProbeResult:
|
|
"""Metadata GET authenticated by one env var (never a generation call)."""
|
|
key = os.getenv(env_var, "").strip()
|
|
if not key:
|
|
return ProbeResult(name, "skip", "(not configured)")
|
|
resp = _http_get(url, headers={"Authorization": f"{scheme} {key}"}, timeout=timeout)
|
|
return _classify_http(name, resp, env_var)
|
|
|
|
|
|
def _probe_browser(timeout: float) -> ProbeResult:
|
|
if not _browser_available():
|
|
return ProbeResult("Browser", "skip", "(not configured)")
|
|
ok, detail = _launch_browser_probe(timeout)
|
|
return ProbeResult("Browser", "pass" if ok else "fail", f"({detail})")
|
|
|
|
|
|
def _probe_audio(kind: str, config: dict, timeout: float) -> ProbeResult:
|
|
"""Shared TTS/STT metadata probe (voices/models list GET only)."""
|
|
name = kind.upper()
|
|
provider = (((config.get(kind) or {}).get("provider")) or "").strip().lower()
|
|
if provider in _LOCAL_AUDIO_PROVIDERS:
|
|
return ProbeResult(name, "skip", f"(provider '{provider or 'local'}' — no remote backend to probe)")
|
|
if provider not in _AUDIO_PROBES:
|
|
return ProbeResult(name, "skip", f"(provider '{provider}' — no live probe implemented)")
|
|
url, env_var, scheme = _AUDIO_PROBES[provider]
|
|
key = os.getenv(env_var, "").strip()
|
|
if not key:
|
|
return ProbeResult(name, "warn", f"(provider '{provider}' configured but {env_var} is not set)")
|
|
headers = {"xi-api-key": key} if scheme == "xi" else {"Authorization": f"Bearer {key}"}
|
|
result = _classify_http(name, _http_get(url, headers=headers, timeout=timeout), env_var)
|
|
result.detail = f"({provider}) {result.detail}"
|
|
return result
|
|
|
|
|
|
# ── Orchestration ──
|
|
|
|
_REPORTERS = {"pass": check_ok, "warn": check_warn, "fail": check_fail}
|
|
|
|
|
|
def _report(result: ProbeResult, issues: List[str]) -> None:
|
|
reporter = _REPORTERS.get(result.status)
|
|
if reporter is None: # skip
|
|
check_info(f"{result.name} {result.detail} — skipped")
|
|
return
|
|
reporter(result.name, result.detail)
|
|
if result.status == "fail":
|
|
issues.append(f"Live probe failed: {result.name} {result.detail}")
|
|
|
|
|
|
def _run_one(name: str, fn: Callable[[], ProbeResult], issues: List[str]) -> ProbeResult:
|
|
"""Run one probe with a catch-all so a crash never kills doctor."""
|
|
try:
|
|
result = fn()
|
|
except TimeoutError as exc:
|
|
result = ProbeResult(name, "fail", f"(timed out: {exc})")
|
|
except Exception as exc:
|
|
msg = str(exc) or exc.__class__.__name__
|
|
result = ProbeResult(name, "fail", f"(timed out: {msg})" if "time" in msg.lower() else f"({msg})")
|
|
_report(result, issues)
|
|
return result
|
|
|
|
|
|
def run_live_checks(issues: List[str]) -> List[ProbeResult]:
|
|
"""Run one bounded, read-only probe per configured tool backend — sequential by design (predictable output
|
|
ordering). Appends a remediation line to ``issues`` per failed probe; skipped backends never append."""
|
|
from hermes_cli.config import load_config_readonly
|
|
config = load_config_readonly()
|
|
try:
|
|
timeout = float((config.get("doctor") or {}).get("live_probe_timeout", DEFAULT_PROBE_TIMEOUT))
|
|
except (TypeError, ValueError):
|
|
timeout = DEFAULT_PROBE_TIMEOUT
|
|
timeout = max(1.0, timeout)
|
|
_section("Live Backend Probes (opt-in, real calls)")
|
|
results: List[ProbeResult] = [
|
|
_run_one(name, lambda n=name, spec=spec: _keyed_probe(n, *spec, timeout), issues)
|
|
for name, spec in _KEYED_PROBES.items()
|
|
]
|
|
results.append(_run_one("Browser", lambda: _probe_browser(timeout), issues))
|
|
servers = config.get("mcp_servers") or {}
|
|
if isinstance(servers, dict) and servers:
|
|
for name in sorted(servers):
|
|
def _probe(n=name, e=servers[name]) -> ProbeResult:
|
|
if not isinstance(e, dict):
|
|
return ProbeResult(f"MCP: {n}", "skip", "(malformed config entry)")
|
|
return ProbeResult(f"MCP: {n}", "pass", f"({len(_probe_mcp_server(n, e, timeout))} tool(s))")
|
|
results.append(_run_one(f"MCP: {name}", _probe, issues))
|
|
else:
|
|
results.append(ProbeResult("MCP", "skip", "(no servers configured)"))
|
|
_report(results[-1], issues)
|
|
for kind in ("tts", "stt"):
|
|
results.append(_run_one(kind.upper(), lambda k=kind: _probe_audio(k, config, timeout), issues))
|
|
return results
|
|
|
|
|
|
def maybe_run_live_checks(args, issues: List[str]):
|
|
"""Called from ``run_doctor`` after the static checks; no-op (None) unless ``--live`` was passed.
|
|
A crash anywhere in the live subsystem must never break doctor."""
|
|
if not getattr(args, "live", False):
|
|
return None
|
|
try:
|
|
return run_live_checks(issues)
|
|
except Exception as exc: # catch-all: doctor must survive
|
|
check_warn("Live backend probes crashed", f"({exc})")
|
|
return None
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
|
|
ELEVENLABS_VOICES_URL = "https://api.elevenlabs.io/v1/voices"
|
|
|
|
FAL_MODELS_URL = "https://fal.ai/api/models?page=1"
|
|
|
|
FIRECRAWL_HEALTH_URL = "https://api.firecrawl.dev/v2/team/credit-usage"
|
|
|
|
GROQ_MODELS_URL = "https://api.groq.com/openai/v1/models"
|
|
|
|
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
|
|
# ---- END PLUGIN-COMPAT ----
|