From 738925640fa729d83842e30797f8c42f976c2493 Mon Sep 17 00:00:00 2001 From: PowerCreek Date: Fri, 22 May 2026 21:19:56 +0000 Subject: [PATCH] doctor: probe devagentic graph when skills/memory graph mode is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When DEVAGENTIC_SKILLS_GRAPH=1 or DEVAGENTIC_MEMORY_GRAPH=1, the adapters silently fall back to file lookups on auth / network / DNS failures (their loss-tolerant contract is correct). Operators who opted into graph mode had no diagnostic surface to learn why lookups weren't being consulted — `hermes doctor` made zero DEVAGENTIC-related checks. Add a `Devagentic Graph` section to run_doctor that sends a one-shot `{ __typename }` probe when either flag is set, and surfaces the specific failure kind: auth failed → set DEVAGENTIC_API_KEY not found → check DEVAGENTIC_BASE_URL points at graph-enabled instance unreachable → details from URLError/OSError USER_ID unresolved → set env or run inside a profile reachable → 200 OK with auth + user_id Silent when both flags are False (preserves byte-stable default for users who never wired devagentic). Closes #17. --- hermes_cli/doctor.py | 119 +++++++++++++ .../test_doctor_devagentic_graph.py | 159 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 tests/hermes_cli/test_doctor_devagentic_graph.py diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 613815025116..66653cec0e8e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -238,6 +238,123 @@ def _check_gateway_service_linger(issues: list[str]) -> None: check_warn("Could not verify systemd linger", f"({linger_detail})") +def _check_devagentic_graph() -> None: + """Probe devagentic's GraphQL surface when skills or memory graph + mode is enabled. Surfaces the specific failure kind (auth / + unreachable / not found / user_id unresolved) instead of leaving + the operator to grep DEBUG logs (see #17). + + Silent when both adapters are in default file-fallback mode. + """ + import json as _json + import os as _os + import urllib.error as _urllib_error + import urllib.request as _urllib_request + + try: + from agent.devagentic_skills import ( + graph_enabled as _skills_enabled, + _base_url as _skills_base, + ) + from agent.devagentic_memory import graph_enabled as _memory_enabled + except Exception as exc: + # Adapters always import; a failure here would be a hermes + # packaging defect, not something the operator can fix. + check_warn("Devagentic graph adapters not importable", str(exc)) + return + + skills_on = _skills_enabled() + memory_on = _memory_enabled() + if not (skills_on or memory_on): + # File-fallback path is the byte-stable default. Don't print + # a section header for the silent case — that's the canonical + # operational mode for users who never wired devagentic. + return + + _section("Devagentic Graph") + enabled_modes = [] + if skills_on: + enabled_modes.append("skills") + if memory_on: + enabled_modes.append("memory") + check_info(f"Graph mode active for: {', '.join(enabled_modes)}") + + base = _skills_base() + user = (_os.environ.get("DEVAGENTIC_USER_ID") or "").strip() + if not user: + try: + from hermes_cli.profiles import get_active_profile_name + user = (get_active_profile_name() or "").strip() + except Exception: + user = "" + if not user: + check_fail( + "DEVAGENTIC_USER_ID unresolved", + "set DEVAGENTIC_USER_ID or run inside a hermes profile", + ) + return + + api_key = (_os.environ.get("DEVAGENTIC_API_KEY") or "").strip() + body = _json.dumps( + {"query": "{ __typename }", "variables": {}}).encode("utf-8") + req = _urllib_request.Request( + f"{base}/graphql", data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + req.add_header("X-User-Id", user) + if api_key: + req.add_header("Authorization", f"Bearer {api_key}") + + try: + with _urllib_request.urlopen(req, timeout=4.0) as resp: + raw = resp.read().decode("utf-8") + except _urllib_error.HTTPError as exc: + if exc.code in (401, 403): + check_fail( + "Devagentic GraphQL: auth failed", + "set DEVAGENTIC_API_KEY (any non-empty value when " + "devagentic runs in DEVAGENTIC_TRUST_HEADER=1 mode)", + ) + elif exc.code == 404: + check_fail( + "Devagentic GraphQL: not found", + f"{base}/graphql returned 404 — verify " + "DEVAGENTIC_BASE_URL points at a graph-enabled instance", + ) + else: + check_fail( + f"Devagentic GraphQL: HTTP {exc.code}", + f"unexpected status from {base}/graphql", + ) + return + except (_urllib_error.URLError, OSError, TimeoutError) as exc: + check_fail( + "Devagentic GraphQL: unreachable", + f"{base}/graphql — {exc}", + ) + return + + try: + payload = _json.loads(raw) + except Exception as exc: + check_warn("Devagentic GraphQL: response not JSON", str(exc)) + return + if not isinstance(payload, dict): + check_warn("Devagentic GraphQL: unexpected response shape", + "response was not a JSON object") + return + if payload.get("errors"): + check_warn( + "Devagentic GraphQL: returned errors", + str(payload.get("errors"))[:200], + ) + return + check_ok( + "Devagentic GraphQL reachable", + f"{base}/graphql — auth + user_id OK", + ) + + _APIKEY_PROVIDERS_CACHE: list | None = None @@ -1913,6 +2030,8 @@ def _gh_authenticated() -> bool: except Exception as _e: check_warn(f"{_active_memory_provider} check failed", str(_e)) + _check_devagentic_graph() + try: from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists import re as _re diff --git a/tests/hermes_cli/test_doctor_devagentic_graph.py b/tests/hermes_cli/test_doctor_devagentic_graph.py new file mode 100644 index 000000000000..16f9e5773701 --- /dev/null +++ b/tests/hermes_cli/test_doctor_devagentic_graph.py @@ -0,0 +1,159 @@ +"""Tests for the `Devagentic Graph` section in `hermes doctor` +(see #17). The probe is silent when both graph_enabled() flags are +False, and surfaces the specific failure kind when either is True.""" + +from __future__ import annotations + +import json +import urllib.error +from io import BytesIO + +import pytest + +from hermes_cli import doctor as doctor_mod + + +def _capture_check_calls(monkeypatch): + """Replace check_ok / check_warn / check_fail / check_info with + capture stubs and return the recorder list. Each entry is + ("ok"|"warn"|"fail"|"info", text, detail).""" + calls: list[tuple[str, str, str]] = [] + + def _ok(text, detail=""): + calls.append(("ok", text, detail)) + + def _warn(text, detail=""): + calls.append(("warn", text, detail)) + + def _fail(text, detail=""): + calls.append(("fail", text, detail)) + + def _info(text): + calls.append(("info", text, "")) + + def _section(title): + calls.append(("section", title, "")) + + monkeypatch.setattr(doctor_mod, "check_ok", _ok) + monkeypatch.setattr(doctor_mod, "check_warn", _warn) + monkeypatch.setattr(doctor_mod, "check_fail", _fail) + monkeypatch.setattr(doctor_mod, "check_info", _info) + monkeypatch.setattr(doctor_mod, "_section", _section) + return calls + + +def _enable_graph_modes(monkeypatch, skills=True, memory=False): + import agent.devagentic_skills as _skills + import agent.devagentic_memory as _memory + monkeypatch.setattr(_skills, "graph_enabled", + lambda: skills, raising=False) + monkeypatch.setattr(_memory, "graph_enabled", + lambda: memory, raising=False) + + +def test_silent_when_both_graph_modes_disabled(monkeypatch): + calls = _capture_check_calls(monkeypatch) + _enable_graph_modes(monkeypatch, skills=False, memory=False) + doctor_mod._check_devagentic_graph() + assert calls == [] + + +def test_reports_unresolved_user_id(monkeypatch): + calls = _capture_check_calls(monkeypatch) + _enable_graph_modes(monkeypatch, skills=True) + monkeypatch.delenv("DEVAGENTIC_USER_ID", raising=False) + import sys as _sys + fake = type("F", (), {"get_active_profile_name": staticmethod( + lambda: None)})() + monkeypatch.setitem(_sys.modules, "hermes_cli.profiles", fake) + + doctor_mod._check_devagentic_graph() + + kinds = [c[0] for c in calls] + texts = [c[1] for c in calls] + assert "section" in kinds + assert any("DEVAGENTIC_USER_ID unresolved" in t for t in texts) + + +def test_reports_auth_failure_on_401(monkeypatch): + calls = _capture_check_calls(monkeypatch) + _enable_graph_modes(monkeypatch, skills=True) + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + + def _raise(*a, **k): + raise urllib.error.HTTPError( + "http://x/graphql", 401, "Unauthorized", {}, None) + + import urllib.request as _ur + monkeypatch.setattr(_ur, "urlopen", _raise) + + doctor_mod._check_devagentic_graph() + + fail = [c for c in calls if c[0] == "fail"] + assert fail, calls + assert any("auth failed" in c[1] for c in fail) + assert any("DEVAGENTIC_API_KEY" in c[2] for c in fail) + + +def test_reports_not_found_on_404(monkeypatch): + calls = _capture_check_calls(monkeypatch) + _enable_graph_modes(monkeypatch, memory=True, skills=False) + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + + def _raise(*a, **k): + raise urllib.error.HTTPError( + "http://x/graphql", 404, "Not Found", {}, None) + + import urllib.request as _ur + monkeypatch.setattr(_ur, "urlopen", _raise) + + doctor_mod._check_devagentic_graph() + + fail = [c for c in calls if c[0] == "fail"] + assert any("not found" in c[1].lower() for c in fail), calls + + +def test_reports_unreachable_on_urlerror(monkeypatch): + calls = _capture_check_calls(monkeypatch) + _enable_graph_modes(monkeypatch, skills=True) + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + + def _raise(*a, **k): + raise urllib.error.URLError("connection refused") + + import urllib.request as _ur + monkeypatch.setattr(_ur, "urlopen", _raise) + + doctor_mod._check_devagentic_graph() + + fail = [c for c in calls if c[0] == "fail"] + assert any("unreachable" in c[1].lower() for c in fail), calls + + +def test_reports_ok_on_clean_200(monkeypatch): + calls = _capture_check_calls(monkeypatch) + _enable_graph_modes(monkeypatch, skills=True, memory=True) + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return json.dumps( + {"data": {"__typename": "Query"}}).encode("utf-8") + + import urllib.request as _ur + monkeypatch.setattr(_ur, "urlopen", lambda *a, **k: _Resp()) + + doctor_mod._check_devagentic_graph() + + ok = [c for c in calls if c[0] == "ok"] + assert ok, calls + assert any("reachable" in c[1].lower() for c in ok) + info = [c for c in calls if c[0] == "info"] + assert any("skills" in c[1] and "memory" in c[1] for c in info), \ + "Both modes should be listed in the info row"