refactor(reasoning): unify per-model reasoning resolution behind a single chokepoint

Collapse the six per-surface copies of override-then-global resolution
(CLI startup, gateway, TUI, cron, /model switch, fallback activation)
onto one shared resolve_reasoning_config() in hermes_constants.

Also fixes the gateway resolving reasoning against config model.default
instead of the session's effective model: after a session-only /model
switch, the switched model's override now applies (gateway message paths
pass the resolved session model through _resolve_session_reasoning_config;
/reasoning status reads the session model override).

Cleanup: drop docs/PER_MODEL_REASONING.md (duplicates the website docs
page), drop the change-detector _config_version test (no bump needed —
deep-merge handles new keys), remove a stale plan-reference comment.

Adds chokepoint contract tests (13) and gateway session-effective-model
regression tests (2).
This commit is contained in:
Teknium 2026-07-14 07:16:54 -07:00
parent d9cdb81923
commit e81d18dfb4
11 changed files with 302 additions and 253 deletions

View file

@ -2074,40 +2074,19 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Re-resolve reasoning_config from per-model override ──
# The new model may have a different reasoning_effort override. Re-read
# config so the override takes effect immediately on /model switch.
# Try both agent.model (normalized, e.g. "claude-opus-4-5") AND the raw
# config default (user's original spelling, e.g. "claude-opus-4.5") so
# override keys match regardless of how downstream consumers normalized
# the input. See plan FINDING #7 + session follow-up.
# config so the override takes effect immediately on /model switch —
# resolved through the shared chokepoint (per-model > global; YAML
# boolean False = disabled).
try:
from hermes_constants import (
parse_reasoning_effort,
resolve_per_model_reasoning_effort,
)
from hermes_constants import resolve_reasoning_config
from hermes_cli.config import load_config as _sm_load_config
_reasoning_cfg = _sm_load_config() or {}
_sm_overrides = (_reasoning_cfg.get("agent") or {}).get("reasoning_overrides", {}) or {}
# Try the normalized agent.model first, then the raw config default
_sm_raw_model_default = str((_reasoning_cfg.get("model") or {}).get("default", "") or "").strip()
_sm_per_model = None
for _candidate in (agent.model, _sm_raw_model_default):
if _candidate:
_sm_per_model = resolve_per_model_reasoning_effort(_candidate, _sm_overrides)
if _sm_per_model is not None:
break
if _sm_per_model is not None:
agent.reasoning_config = _sm_per_model
logger.info(
"switch_model: reasoning_config resolved to per-model override for %s: %s",
agent.model, _sm_per_model,
)
else:
# Raw value — a YAML boolean False means thinking disabled,
# see parse_reasoning_effort. Do NOT str()/strip() coerce.
_sm_global = (_reasoning_cfg.get("agent") or {}).get("reasoning_effort", "")
agent.reasoning_config = parse_reasoning_effort(_sm_global)
logger.info("switch_model: reasoning_config resolved to global effort: %s", _sm_global or "(none)")
agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model)
logger.info(
"switch_model: reasoning_config resolved for %s: %s",
agent.model, agent.reasoning_config,
)
except Exception as _reasoning_err:
logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err)

View file

@ -1636,37 +1636,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
)
# Re-resolve reasoning_config for the new fallback model (Closes #21256).
# Per-model override (if any) takes precedence, else global reasoning_effort.
# Wrapped in try/except because config load failure must not kill the swap.
# Shared chokepoint: per-model override > global reasoning_effort
# (YAML boolean False = disabled). Wrapped in try/except because a
# config load failure must not kill the swap.
try:
from hermes_cli.config import load_config
from hermes_constants import parse_reasoning_effort, resolve_per_model_reasoning_effort
from hermes_constants import resolve_reasoning_config
_fb_cfg = load_config() or {}
_fb_agent_cfg = _fb_cfg.get("agent", {}) or {}
_fb_overrides = _fb_agent_cfg.get("reasoning_overrides", {}) or {}
_fb_per_model = resolve_per_model_reasoning_effort(agent.model, _fb_overrides)
if _fb_per_model is not None:
agent.reasoning_config = _fb_per_model
logger.info(
"Fallback %s: reasoning_config resolved to per-model override: %s",
agent.model, _fb_per_model,
)
else:
# Raw value — a YAML boolean False means thinking disabled,
# see parse_reasoning_effort. Do NOT coerce with ``or ""``.
_fb_global_effort = _fb_agent_cfg.get("reasoning_effort", "")
agent.reasoning_config = parse_reasoning_effort(_fb_global_effort)
if agent.reasoning_config:
logger.info(
"Fallback %s: reasoning_config resolved to global effort: %s",
agent.model, _fb_global_effort,
)
else:
logger.info(
"Fallback %s: reasoning_config resolved to None (disabled or default)",
agent.model,
)
agent.reasoning_config = resolve_reasoning_config(
load_config() or {}, agent.model
)
logger.info(
"Fallback %s: reasoning_config resolved: %s",
agent.model, agent.reasoning_config,
)
except Exception as _reasoning_err:
logger.debug(
"Failed to resolve reasoning_config for fallback %s; keeping current: %s",