Merge pull request #15274 from NousResearch/bb/tui-null-config-guard

fix(tui): tolerate + warn on null sections in config.yaml
This commit is contained in:
brooklyn! 2026-04-24 13:02:12 -05:00 committed by GitHub
commit 6f1eed3968
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 210 additions and 49 deletions

View file

@ -27,16 +27,22 @@ def test_make_agent_passes_resolved_provider():
"agent": {"system_prompt": "test"}, "agent": {"system_prompt": "test"},
} }
with patch("tui_gateway.server._load_cfg", return_value=fake_cfg), \ with (
patch("tui_gateway.server._get_db", return_value=MagicMock()), \ patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._load_tool_progress_mode", return_value="compact"), \ patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_reasoning_config", return_value=None), \ patch("tui_gateway.server._load_tool_progress_mode", return_value="compact"),
patch("tui_gateway.server._load_service_tier", return_value=None), \ patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None), \ patch("tui_gateway.server._load_service_tier", return_value=None),
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime) as mock_resolve, \ patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch("run_agent.AIAgent") as mock_agent: patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
) as mock_resolve,
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent from tui_gateway.server import _make_agent
_make_agent("sid-1", "key-1") _make_agent("sid-1", "key-1")
mock_resolve.assert_called_once_with(requested=None) mock_resolve.assert_called_once_with(requested=None)
@ -46,3 +52,98 @@ 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_probe_config_health_flags_null_sections():
"""Bare YAML keys (`agent:` with no value) parse as None and silently
drop nested settings; probe must surface them so users can fix."""
from tui_gateway.server import _probe_config_health
assert _probe_config_health({"agent": {"x": 1}}) == ""
assert _probe_config_health({}) == ""
msg = _probe_config_health({"agent": None, "display": None, "model": {}})
assert "agent" in msg and "display" in msg
assert "model" not in msg
def test_probe_config_health_flags_null_personalities_with_active_personality():
from tui_gateway.server import _probe_config_health
msg = _probe_config_health(
{
"agent": {"personalities": None},
"display": {"personality": "kawaii"},
"model": {},
}
)
assert "display.personality" in msg
assert "agent.personalities" in msg
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."""
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
def test_make_agent_tolerates_null_personalities_with_active_personality():
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,
}
cfg = {
"agent": {"personalities": None},
"display": {"personality": "kawaii"},
"model": {"default": "glm-5"},
}
with (
patch("tui_gateway.server._load_cfg", return_value=cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("cli.load_cli_config", return_value={"agent": {"personalities": None}}),
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-personality", "key-null-personality")
assert mock_agent.called
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None

View file

@ -61,7 +61,11 @@ def _panic_hook(exc_type, exc_value, exc_tb):
# Stderr goes through to the TUI as a gateway.stderr Activity line — # Stderr goes through to the TUI as a gateway.stderr Activity line —
# the first line here is what the user will see without opening any # the first line here is what the user will see without opening any
# log files. Rest of the stack is still in the log for full context. # log files. Rest of the stack is still in the log for full context.
first = str(exc_value).strip().splitlines()[0] if str(exc_value).strip() else exc_type.__name__ first = (
str(exc_value).strip().splitlines()[0]
if str(exc_value).strip()
else exc_type.__name__
)
print(f"[gateway-crash] {exc_type.__name__}: {first}", file=sys.stderr, flush=True) print(f"[gateway-crash] {exc_type.__name__}: {first}", file=sys.stderr, flush=True)
# Chain to the default hook so the process still terminates normally. # Chain to the default hook so the process still terminates normally.
sys.__excepthook__(exc_type, exc_value, exc_tb) sys.__excepthook__(exc_type, exc_value, exc_tb)
@ -593,13 +597,17 @@ 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 +617,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:
@ -816,6 +824,39 @@ def _probe_credentials(agent) -> str:
return "" return ""
def _probe_config_health(cfg: dict) -> str:
"""Flag bare YAML keys (`agent:` with no value → None) that silently
drop nested settings. Returns warning or ''."""
if not isinstance(cfg, dict):
return ""
warnings: list[str] = []
null_keys = sorted(k for k, v in cfg.items() if v is None)
if not null_keys:
pass
else:
keys = ", ".join(f"`{k}`" for k in null_keys)
warnings.append(
f"config.yaml has empty section(s): {keys}. "
f"Remove the line(s) or set them to `{{}}` — "
f"empty sections silently drop nested settings."
)
display_cfg = cfg.get("display")
agent_cfg = cfg.get("agent")
if isinstance(display_cfg, dict):
personality = str(display_cfg.get("personality", "") or "").strip().lower()
if (
personality
and personality not in {"default", "none", "neutral"}
and isinstance(agent_cfg, dict)
and agent_cfg.get("personalities") is None
):
warnings.append(
"`display.personality` is set but `agent.personalities` is empty/null; "
"personality overlay will be skipped."
)
return " ".join(warnings).strip()
def _session_info(agent) -> dict: def _session_info(agent) -> dict:
info: dict = { info: dict = {
"model": getattr(agent, "model", ""), "model": getattr(agent, "model", ""),
@ -1104,20 +1145,22 @@ 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", {}) 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
personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) personalities = (
(_load_full_cfg().get("agent") or {}).get("personalities", {}) or {}
)
except Exception: except Exception:
personalities = cfg.get("agent", {}).get("personalities", {}) personalities = (cfg.get("agent") or {}).get("personalities", {}) or {}
pval = personalities.get(name) pval = personalities.get(name)
if pval is None: if pval is None:
return "" return ""
@ -1139,15 +1182,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 +1300,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)
@ -1503,6 +1546,10 @@ def _(rid, params: dict) -> dict:
warn = _probe_credentials(agent) warn = _probe_credentials(agent)
if warn: if warn:
info["credential_warning"] = warn info["credential_warning"] = warn
cfg_warn = _probe_config_health(_load_cfg())
if cfg_warn:
info["config_warning"] = cfg_warn
logger.warning(cfg_warn)
_emit("session.info", sid, info) _emit("session.info", sid, info)
except Exception as e: except Exception as e:
session["agent_error"] = str(e) session["agent_error"] = str(e)
@ -1649,9 +1696,7 @@ def _(rid, params: dict) -> dict:
return _db_unavailable_error(rid, code=5007) return _db_unavailable_error(rid, code=5007)
title, key = params.get("title", ""), session["session_key"] title, key = params.get("title", ""), session["session_key"]
if not title: if not title:
return _ok( return _ok(rid, {"title": db.get_session_title(key) or "", "session_key": key})
rid, {"title": db.get_session_title(key) or "", "session_key": key}
)
try: try:
db.set_session_title(key, title) db.set_session_title(key, title)
return _ok(rid, {"title": title}) return _ok(rid, {"title": title})
@ -2278,7 +2323,9 @@ def _(rid, params: dict) -> dict:
f.write(trace) f.write(trace)
except Exception: except Exception:
pass pass
print(f"[gateway-turn] {type(e).__name__}: {e}", file=sys.stderr, flush=True) print(
f"[gateway-turn] {type(e).__name__}: {e}", file=sys.stderr, flush=True
)
_emit("error", sid, {"message": str(e)}) _emit("error", sid, {"message": str(e)})
finally: finally:
try: try:
@ -2701,9 +2748,7 @@ def _(rid, params: dict) -> dict:
cfg = _load_cfg() cfg = _load_cfg()
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
sections_cfg = ( sections_cfg = (
display.get("sections") display.get("sections") if isinstance(display.get("sections"), dict) else {}
if isinstance(display.get("sections"), dict)
else {}
) )
nv = str(value or "").strip().lower() nv = str(value or "").strip().lower()
@ -2838,18 +2883,21 @@ 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 +2905,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 +2916,17 @@ 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 +2935,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")
@ -3369,7 +3421,16 @@ def _list_repo_files(root: str) -> list[str]:
if top_result.returncode == 0: if top_result.returncode == 0:
top = top_result.stdout.decode("utf-8", "replace").strip() top = top_result.stdout.decode("utf-8", "replace").strip()
list_result = subprocess.run( list_result = subprocess.run(
["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], [
"git",
"-C",
top,
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
],
capture_output=True, capture_output=True,
timeout=2.0, timeout=2.0,
check=False, check=False,
@ -3378,7 +3439,9 @@ def _list_repo_files(root: str) -> list[str]:
for p in list_result.stdout.decode("utf-8", "replace").split("\0"): for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
if not p: if not p:
continue continue
rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/") rel = os.path.relpath(os.path.join(top, p), root).replace(
os.sep, "/"
)
# Skip parents/siblings of cwd — keep the picker scoped # Skip parents/siblings of cwd — keep the picker scoped
# to root-and-below, matching Cmd-P workspace semantics. # to root-and-below, matching Cmd-P workspace semantics.
if rel.startswith("../"): if rel.startswith("../"):
@ -3512,12 +3575,7 @@ def _(rid, params: dict) -> dict:
# editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with # editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing # `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
# path so explicit navigation intent is preserved. # path so explicit navigation intent is preserved.
if ( if is_context and path_part and "/" not in path_part and prefix_tag != "folder":
is_context
and path_part
and "/" not in path_part
and prefix_tag != "folder"
):
root = os.getcwd() root = os.getcwd()
ranked: list[tuple[tuple[int, int], str, str]] = [] ranked: list[tuple[tuple[int, int], str, str]] = []
for rel in _list_repo_files(root): for rel in _list_repo_files(root):
@ -3721,7 +3779,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:
@ -3943,9 +4001,7 @@ def _(rid, params: dict) -> dict:
voice_cfg = _load_cfg().get("voice", {}) voice_cfg = _load_cfg().get("voice", {})
start_continuous( start_continuous(
on_transcript=lambda t: _voice_emit( on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
"voice.transcript", {"text": t}
),
on_status=lambda s: _voice_emit("voice.status", {"state": s}), on_status=lambda s: _voice_emit("voice.status", {"state": s}),
on_silent_limit=lambda: _voice_emit( on_silent_limit=lambda: _voice_emit(
"voice.transcript", {"no_speech_limit": True} "voice.transcript", {"no_speech_limit": True}

View file

@ -142,6 +142,10 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
sys(`warning: ${info.credential_warning}`) sys(`warning: ${info.credential_warning}`)
} }
if (info?.config_warning) {
sys(`warning: ${info.config_warning}`)
}
if (msg) { if (msg) {
sys(msg) sys(msg)
} }

View file

@ -93,7 +93,7 @@ export interface SetupStatusResponse {
// ── Session lifecycle ──────────────────────────────────────────────── // ── Session lifecycle ────────────────────────────────────────────────
export interface SessionCreateResponse { export interface SessionCreateResponse {
info?: SessionInfo & { credential_warning?: string } info?: SessionInfo & { config_warning?: string; credential_warning?: string }
session_id: string session_id: string
} }