From fd9b692d330f3b3d7e6a1bdcb50a11ca01e2eb13 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 12:43:09 -0500 Subject: [PATCH] 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. --- tests/tui_gateway/test_make_agent_provider.py | 28 +++++++++++++ tui_gateway/server.py | 42 +++++++++---------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/tests/tui_gateway/test_make_agent_provider.py b/tests/tui_gateway/test_make_agent_provider.py index bdc7fecf4..3e899be32 100644 --- a/tests/tui_gateway/test_make_agent_provider.py +++ b/tests/tui_gateway/test_make_agent_provider.py @@ -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["api_key"] == "sk-test-key" 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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cc2d7b08d..4dbb8318a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -593,13 +593,13 @@ def _coerce_statusbar(raw) -> str: def _load_reasoning_config() -> dict | None: 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) def _load_service_tier() -> str | None: 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"}: return None @@ -609,11 +609,11 @@ def _load_service_tier() -> str | None: 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: - raw = _load_cfg().get("display", {}).get("tool_progress", "all") + raw = (_load_cfg().get("display") or {}).get("tool_progress", "all") if raw is False: return "off" if raw is True: @@ -1104,20 +1104,20 @@ def _wire_callbacks(sid: str): def _resolve_personality_prompt(cfg: dict) -> str: """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"): return "" try: 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: try: 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: - personalities = cfg.get("agent", {}).get("personalities", {}) + personalities = (cfg.get("agent") or {}).get("personalities", {}) pval = personalities.get(name) if pval is None: return "" @@ -1139,15 +1139,15 @@ def _available_personalities(cfg: dict | None = None) -> dict: try: 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: try: 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: 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]: @@ -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 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: system_prompt = _resolve_personality_prompt(cfg) runtime = resolve_runtime_provider(requested=None) @@ -2838,18 +2838,18 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": return _ok( - rid, {"value": _load_cfg().get("display", {}).get("skin", "default")} + rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")} ) if key == "personality": return _ok( - rid, {"value": _load_cfg().get("display", {}).get("personality", "default")} + rid, {"value": (_load_cfg().get("display") or {}).get("personality", "default")} ) if key == "reasoning": 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 = ( "show" - if bool(cfg.get("display", {}).get("show_reasoning", False)) + if bool((cfg.get("display") or {}).get("show_reasoning", False)) else "hide" ) return _ok(rid, {"value": effort, "display": display}) @@ -2857,7 +2857,7 @@ def _(rid, params: dict) -> dict: allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) raw = ( str( - _load_cfg().get("display", {}).get("details_mode", "collapsed") + (_load_cfg().get("display") or {}).get("details_mode", "collapsed") or "collapsed" ) .strip() @@ -2868,13 +2868,13 @@ def _(rid, params: dict) -> dict: if key == "thinking_mode": allowed_tm = frozenset({"collapsed", "truncated", "full"}) 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: nv = raw else: dm = ( str( - cfg.get("display", {}).get("details_mode", "collapsed") + (cfg.get("display") or {}).get("details_mode", "collapsed") or "collapsed" ) .strip() @@ -2883,7 +2883,7 @@ def _(rid, params: dict) -> dict: nv = "full" if dm == "expanded" else "collapsed" return _ok(rid, {"value": nv}) 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"}) if key == "statusbar": 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) elif name == "prompt" and agent: 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._cached_system_prompt = None elif name == "compress" and agent: