fix(tui): tolerate null top-level sections in config.yaml

YAML parses bare keys like `agent:` or `display:` as None. `dict.get(key, {})`
returns that None instead of the default (defaults only fire on missing keys),
so every `cfg.get("agent", {}).get(...)` chain in tui_gateway/server.py
crashed agent init with `'NoneType' object has no attribute 'get'`.

Guard all 21 sites with `(cfg.get(X) or {})`. Regression test covers the
null-section init path reported on Twitter against the new TUI.
This commit is contained in:
Brooklyn Nicholson 2026-04-24 12:43:09 -05:00
parent c61547c067
commit fd9b692d33
2 changed files with 49 additions and 21 deletions

View file

@ -46,3 +46,31 @@ def test_make_agent_passes_resolved_provider():
assert call_kwargs.kwargs["base_url"] == "https://api.anthropic.com" assert call_kwargs.kwargs["base_url"] == "https://api.anthropic.com"
assert call_kwargs.kwargs["api_key"] == "sk-test-key" assert call_kwargs.kwargs["api_key"] == "sk-test-key"
assert call_kwargs.kwargs["api_mode"] == "anthropic_messages" assert call_kwargs.kwargs["api_mode"] == "anthropic_messages"
def test_make_agent_tolerates_null_config_sections():
"""Bare `agent:` / `display:` keys in ~/.hermes/config.yaml parse as
None. cfg.get("agent", {}) returns None (default only fires on missing
key), so downstream .get() chains must be guarded. Reported via Twitter
against the new TUI; CLI path is unaffected."""
fake_runtime = {
"provider": "openrouter",
"base_url": "https://api.synthetic.new/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
null_cfg = {"agent": None, "display": None, "model": {"default": "glm-5"}}
with patch("tui_gateway.server._load_cfg", return_value=null_cfg), \
patch("tui_gateway.server._get_db", return_value=MagicMock()), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime), \
patch("run_agent.AIAgent") as mock_agent:
from tui_gateway.server import _make_agent
_make_agent("sid-null", "key-null")
assert mock_agent.called

View file

@ -593,13 +593,13 @@ def _coerce_statusbar(raw) -> str:
def _load_reasoning_config() -> dict | None: def _load_reasoning_config() -> dict | None:
from hermes_constants import parse_reasoning_effort from hermes_constants import parse_reasoning_effort
effort = str(_load_cfg().get("agent", {}).get("reasoning_effort", "") or "").strip() effort = str((_load_cfg().get("agent") or {}).get("reasoning_effort", "") or "").strip()
return parse_reasoning_effort(effort) return parse_reasoning_effort(effort)
def _load_service_tier() -> str | None: def _load_service_tier() -> str | None:
raw = ( raw = (
str(_load_cfg().get("agent", {}).get("service_tier", "") or "").strip().lower() str((_load_cfg().get("agent") or {}).get("service_tier", "") or "").strip().lower()
) )
if not raw or raw in {"normal", "default", "standard", "off", "none"}: if not raw or raw in {"normal", "default", "standard", "off", "none"}:
return None return None
@ -609,11 +609,11 @@ def _load_service_tier() -> str | None:
def _load_show_reasoning() -> bool: def _load_show_reasoning() -> bool:
return bool(_load_cfg().get("display", {}).get("show_reasoning", False)) return bool((_load_cfg().get("display") or {}).get("show_reasoning", False))
def _load_tool_progress_mode() -> str: def _load_tool_progress_mode() -> str:
raw = _load_cfg().get("display", {}).get("tool_progress", "all") raw = (_load_cfg().get("display") or {}).get("tool_progress", "all")
if raw is False: if raw is False:
return "off" return "off"
if raw is True: if raw is True:
@ -1104,20 +1104,20 @@ def _wire_callbacks(sid: str):
def _resolve_personality_prompt(cfg: dict) -> str: def _resolve_personality_prompt(cfg: dict) -> str:
"""Resolve the active personality into a system prompt string.""" """Resolve the active personality into a system prompt string."""
name = (cfg.get("display", {}).get("personality", "") or "").strip().lower() name = ((cfg.get("display") or {}).get("personality", "") or "").strip().lower()
if not name or name in ("default", "none", "neutral"): if not name or name in ("default", "none", "neutral"):
return "" return ""
try: try:
from cli import load_cli_config from cli import load_cli_config
personalities = load_cli_config().get("agent", {}).get("personalities", {}) personalities = (load_cli_config().get("agent") or {}).get("personalities", {})
except Exception: except Exception:
try: try:
from hermes_cli.config import load_config as _load_full_cfg from hermes_cli.config import load_config as _load_full_cfg
personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) personalities = (_load_full_cfg().get("agent") or {}).get("personalities", {})
except Exception: except Exception:
personalities = cfg.get("agent", {}).get("personalities", {}) personalities = (cfg.get("agent") or {}).get("personalities", {})
pval = personalities.get(name) pval = personalities.get(name)
if pval is None: if pval is None:
return "" return ""
@ -1139,15 +1139,15 @@ def _available_personalities(cfg: dict | None = None) -> dict:
try: try:
from cli import load_cli_config from cli import load_cli_config
return load_cli_config().get("agent", {}).get("personalities", {}) or {} return (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
except Exception: except Exception:
try: try:
from hermes_cli.config import load_config as _load_full_cfg from hermes_cli.config import load_config as _load_full_cfg
return _load_full_cfg().get("agent", {}).get("personalities", {}) or {} return (_load_full_cfg().get("agent") or {}).get("personalities", {}) or {}
except Exception: except Exception:
cfg = cfg or _load_cfg() cfg = cfg or _load_cfg()
return cfg.get("agent", {}).get("personalities", {}) or {} return (cfg.get("agent") or {}).get("personalities", {}) or {}
def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]: def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]:
@ -1257,7 +1257,7 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
from hermes_cli.runtime_provider import resolve_runtime_provider from hermes_cli.runtime_provider import resolve_runtime_provider
cfg = _load_cfg() cfg = _load_cfg()
system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" system_prompt = (cfg.get("agent") or {}).get("system_prompt", "") or ""
if not system_prompt: if not system_prompt:
system_prompt = _resolve_personality_prompt(cfg) system_prompt = _resolve_personality_prompt(cfg)
runtime = resolve_runtime_provider(requested=None) runtime = resolve_runtime_provider(requested=None)
@ -2838,18 +2838,18 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
if key == "skin": if key == "skin":
return _ok( return _ok(
rid, {"value": _load_cfg().get("display", {}).get("skin", "default")} rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
) )
if key == "personality": if key == "personality":
return _ok( return _ok(
rid, {"value": _load_cfg().get("display", {}).get("personality", "default")} rid, {"value": (_load_cfg().get("display") or {}).get("personality", "default")}
) )
if key == "reasoning": if key == "reasoning":
cfg = _load_cfg() cfg = _load_cfg()
effort = str(cfg.get("agent", {}).get("reasoning_effort", "medium") or "medium") effort = str((cfg.get("agent") or {}).get("reasoning_effort", "medium") or "medium")
display = ( display = (
"show" "show"
if bool(cfg.get("display", {}).get("show_reasoning", False)) if bool((cfg.get("display") or {}).get("show_reasoning", False))
else "hide" else "hide"
) )
return _ok(rid, {"value": effort, "display": display}) return _ok(rid, {"value": effort, "display": display})
@ -2857,7 +2857,7 @@ def _(rid, params: dict) -> dict:
allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
raw = ( raw = (
str( str(
_load_cfg().get("display", {}).get("details_mode", "collapsed") (_load_cfg().get("display") or {}).get("details_mode", "collapsed")
or "collapsed" or "collapsed"
) )
.strip() .strip()
@ -2868,13 +2868,13 @@ def _(rid, params: dict) -> dict:
if key == "thinking_mode": if key == "thinking_mode":
allowed_tm = frozenset({"collapsed", "truncated", "full"}) allowed_tm = frozenset({"collapsed", "truncated", "full"})
cfg = _load_cfg() cfg = _load_cfg()
raw = str(cfg.get("display", {}).get("thinking_mode", "") or "").strip().lower() raw = str((cfg.get("display") or {}).get("thinking_mode", "") or "").strip().lower()
if raw in allowed_tm: if raw in allowed_tm:
nv = raw nv = raw
else: else:
dm = ( dm = (
str( str(
cfg.get("display", {}).get("details_mode", "collapsed") (cfg.get("display") or {}).get("details_mode", "collapsed")
or "collapsed" or "collapsed"
) )
.strip() .strip()
@ -2883,7 +2883,7 @@ def _(rid, params: dict) -> dict:
nv = "full" if dm == "expanded" else "collapsed" nv = "full" if dm == "expanded" else "collapsed"
return _ok(rid, {"value": nv}) return _ok(rid, {"value": nv})
if key == "compact": if key == "compact":
on = bool(_load_cfg().get("display", {}).get("tui_compact", False)) on = bool((_load_cfg().get("display") or {}).get("tui_compact", False))
return _ok(rid, {"value": "on" if on else "off"}) return _ok(rid, {"value": "on" if on else "off"})
if key == "statusbar": if key == "statusbar":
display = _load_cfg().get("display") display = _load_cfg().get("display")
@ -3721,7 +3721,7 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
_apply_personality_to_session(sid, session, new_prompt) _apply_personality_to_session(sid, session, new_prompt)
elif name == "prompt" and agent: elif name == "prompt" and agent:
cfg = _load_cfg() cfg = _load_cfg()
new_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" new_prompt = (cfg.get("agent") or {}).get("system_prompt", "") or ""
agent.ephemeral_system_prompt = new_prompt or None agent.ephemeral_system_prompt = new_prompt or None
agent._cached_system_prompt = None agent._cached_system_prompt = None
elif name == "compress" and agent: elif name == "compress" and agent: