Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
159 changes: 159 additions & 0 deletions tests/hermes_cli/test_doctor_devagentic_graph.py
Original file line number Diff line number Diff line change
@@ -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"