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",

17
cli.py
View file

@ -3917,19 +3917,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
)
# Reasoning config (OpenRouter reasoning effort level)
# Per-model override takes precedence over global effort (Closes #21256).
_reasoning_overrides = CLI_CONFIG["agent"].get("reasoning_overrides", {}) or {}
from hermes_constants import resolve_per_model_reasoning_effort
_per_model_reasoning = resolve_per_model_reasoning_effort(
self.model, _reasoning_overrides
)
self.reasoning_config = (
_per_model_reasoning
if _per_model_reasoning is not None
else _parse_reasoning_config(
CLI_CONFIG["agent"].get("reasoning_effort", "")
)
)
# Per-model override > global reasoning_effort — resolved through the
# shared chokepoint in hermes_constants (Closes #21256).
from hermes_constants import resolve_reasoning_config
self.reasoning_config = resolve_reasoning_config(CLI_CONFIG, self.model)
self.service_tier = _parse_service_tier_config(
CLI_CONFIG["agent"].get("service_tier", "")
)

View file

@ -2988,25 +2988,13 @@ def run_job(
except Exception:
pass
# Reasoning config from config.yaml (per-model override > global)
from hermes_constants import (
parse_reasoning_effort,
resolve_per_model_reasoning_effort,
# Reasoning config from config.yaml (per-model override > global) —
# resolved through the shared chokepoint against the job's effective
# model (per-job override > HERMES_MODEL env > config.yaml default).
from hermes_constants import resolve_reasoning_config
reasoning_config = resolve_reasoning_config(
_cfg if isinstance(_cfg, dict) else {}, str(model)
)
_cron_model_cfg = _cfg.get("model", {}) if isinstance(_cfg.get("model", {}), dict) else {}
_cron_model = str(
_cron_model_cfg.get("default", "") or _cron_model_cfg.get("model", "") or ""
).strip()
_cron_overrides = (_cfg.get("agent", {}) or {}).get("reasoning_overrides", {}) or {}
_cron_per_model = resolve_per_model_reasoning_effort(_cron_model, _cron_overrides)
if _cron_per_model is not None:
reasoning_config = _cron_per_model
else:
# Raw value — a YAML boolean False means thinking disabled,
# see parse_reasoning_effort. Do NOT str()/strip() coerce.
reasoning_config = parse_reasoning_effort(
_cfg.get("agent", {}).get("reasoning_effort", "")
)
# Prefill messages from env or config.yaml. The top-level
# prefill_messages_file key is canonical; agent.prefill_messages_file is

View file

@ -1,101 +0,0 @@
# Hermes Agent Configuration Guide
## Per-Model Reasoning Effort Overrides
You can configure different reasoning effort levels for different models. This allows you to set `high` effort for complex reasoning models like `claude-opus-4.5` while keeping `medium` for faster models like `gemini-flash`.
### Configuration
Edit your `config.yaml` (typically at `~/.hermes/config.yaml`):
```yaml
agent:
reasoning_overrides:
claude-opus-4.5: high
gemini-flash: medium
gpt-4.5: high
```
### Key Matching
The model name matching is **spelling-tolerant**. All of these variations will match:
- `claude-opus-4.5`, `claude-opus-4-5`, `claude-opus.4.5`
- `anthropic/claude-opus-4.5`, `openrouter/anthropic/claude-opus-4.5`
- With or without provider prefixes
Exact matches take precedence over variants.
### Resolution Order
When determining reasoning effort for a model, Hermes checks in this order:
1. **Session override**: `/reasoning high` (current session only)
2. **Per-model override**: `agent.reasoning_overrides.<model>` from config.yaml
3. **Global default**: `agent.reasoning_effort` from config.yaml
### How It Works
The override applies automatically in these scenarios:
- **CLI startup**: Uses the override for the configured default model
- **Gateway messaging**: Each gateway session uses the override for its model
- **Desktop/TUI**: Uses the override for the configured model
- **Model switching**: When you switch models, the reasoning effort updates to the new model's override
- **Fallback activation**: When the primary model fails and Hermes falls back to a secondary model, it uses that fallback model's override
- **Reasoning recovery**: When the primary model recovers after a fallback, the original model's override is restored
### Examples
#### Example 1: High effort for Opus, medium for others
```yaml
agent:
reasoning_overrides:
claude-opus-4.5: high
```
#### Example 2: Different efforts per model
```yaml
agent:
reasoning_overrides:
claude-opus-4.5: high
gemini-2.0-flash: low
gpt-4.5: high
o3-mini: medium
```
#### Example 3: With provider prefixes
```yaml
agent:
reasoning_overrides:
anthropic/claude-opus-4.5: high
google/gemini-2.0-flash: low
```
All of these are equivalent — the provider prefix is optional.
### Disabling Reasoning for Specific Models
Set the override to `none` to disable reasoning for a specific model:
```yaml
agent:
reasoning_overrides:
gemini-flash: none
```
### Troubleshooting
**Override not taking effect?**
- Check the exact model name in your config with `/model`
- Verify the override is under `agent.reasoning_overrides` (not `agent.reasoning_effort`)
- Restart the gateway or CLI session after editing config.yaml
- Check logs for parsing errors
**Override applies but reasoning doesn't work?**
- Not all models support reasoning (e.g., `gemini-flash` has limited support)
- Check the model's documentation for reasoning capability
- Use a model that explicitly supports extended thinking
**Session override not respecting per-model override?**
- Session overrides take precedence (by design)
- Clear the session override with `/reasoning default` to return to the per-model override

View file

@ -4851,39 +4851,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return getattr(self, "_ephemeral_system_prompt", None) or ""
@staticmethod
def _load_reasoning_config() -> dict | None:
def _load_reasoning_config(model: str = "") -> dict | None:
"""Load reasoning effort from config.yaml, respecting per-model overrides.
Reads agent.reasoning_effort from config.yaml. Valid: "none",
"minimal", "low", "medium", "high", "xhigh", "max", "ultra". Returns None to use
default (medium).
Thin wrapper over the shared chokepoint
:func:`hermes_constants.resolve_reasoning_config` (per-model override >
global ``agent.reasoning_effort``; YAML boolean False = disabled).
Closes #21256.
Per-model overrides (agent.reasoning_overrides) take precedence
over the global value when the current model matches a key
(spelling-tolerant). Closes #21256.
Args:
model: The effective model for the calling session. When empty,
the config's ``model.default`` is used.
"""
from hermes_constants import parse_reasoning_effort, resolve_per_model_reasoning_effort
from hermes_constants import resolve_reasoning_config
cfg = _load_gateway_runtime_config()
# Per-model override first
model_cfg = cfg.get("model") or {}
model = str(
(model_cfg.get("default", "") if isinstance(model_cfg, dict) else "")
or (model_cfg.get("model", "") if isinstance(model_cfg, dict) else "")
or ""
).strip()
overrides = (cfg.get("agent") or {}).get("reasoning_overrides", {}) or {}
per_model = resolve_per_model_reasoning_effort(model, overrides)
if per_model is not None:
return per_model
# Global fallback — keep the raw value; coercing with ``or ""`` turns
# a YAML boolean False (``reasoning_effort: false``/``off``/``no``)
# into "", silently re-enabling thinking for users who explicitly
# disabled it.
effort = cfg_get(cfg, "agent", "reasoning_effort", default="")
result = parse_reasoning_effort(effort)
if effort and str(effort).strip() and result is None:
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
return result
return resolve_reasoning_config(cfg, model)
@staticmethod
def _parse_reasoning_command_args(raw_args: str) -> tuple[str, bool]:
@ -4916,8 +4898,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
*,
source: Optional[SessionSource] = None,
session_key: Optional[str] = None,
model: str = "",
) -> dict | None:
"""Resolve reasoning effort for a session, honoring session overrides."""
"""Resolve reasoning effort for a session, honoring session overrides.
Priority: session-scoped ``/reasoning --session`` override >
per-model override (``agent.reasoning_overrides``) > global
``agent.reasoning_effort``. ``model`` should be the session's
*effective* model (session ``/model`` override included) so
per-model overrides track what the session actually runs when
empty, the config's ``model.default`` is used.
"""
resolved_session_key = session_key
if not resolved_session_key and source is not None:
try:
@ -4928,7 +4919,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
overrides = getattr(self, "_session_reasoning_overrides", {}) or {}
if resolved_session_key and resolved_session_key in overrides:
return overrides[resolved_session_key]
return self._load_reasoning_config()
return self._load_reasoning_config(model)
def _set_session_reasoning_override(
self,
@ -13498,7 +13489,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
pr = self._provider_routing
max_iterations = _current_max_iterations()
reasoning_config = self._resolve_session_reasoning_config(source=source)
reasoning_config = self._resolve_session_reasoning_config(
source=source, model=model
)
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
@ -18249,6 +18242,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
reasoning_config = self._resolve_session_reasoning_config(
source=source,
session_key=session_key,
model=model,
)
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()

View file

@ -2658,9 +2658,15 @@ class GatewaySlashCommandsMixin:
_reasoning_source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source)
session_key = self._session_key_for_source(_reasoning_source)
self._show_reasoning = self._load_show_reasoning()
# Use the session's effective model (session /model override wins over
# config default) so per-model reasoning_overrides display correctly.
_session_model = str(
((getattr(self, "_session_model_overrides", {}) or {}).get(session_key) or {}).get("model") or ""
)
self._reasoning_config = self._resolve_session_reasoning_config(
source=event.source,
session_key=session_key,
model=_session_model,
)
def _save_config_key(key_path: str, value):

View file

@ -948,6 +948,67 @@ def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> di
return None
def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
"""Resolve the effective reasoning config for *model* from a config dict.
Single chokepoint for reasoning-effort resolution, shared by every
surface (CLI startup, messaging gateway, Desktop/TUI, cron, ``/model``
switch, fallback activation). Priority:
1. Per-model override from ``agent.reasoning_overrides``
(spelling-tolerant see :func:`resolve_per_model_reasoning_effort`)
2. Global ``agent.reasoning_effort`` the raw value is passed through
so a YAML boolean ``False`` (``reasoning_effort: false``/``off``/
``no``) means "thinking disabled", never silently re-enabled.
Session-scoped overrides (gateway ``/reasoning --session``) are resolved
by the caller BEFORE this function they always win.
Args:
cfg: A loaded config dict (any of the three loaders' shapes — only
the ``agent`` and ``model`` sections are read).
model: The effective model for this surface/session. When empty,
it is derived from the config's ``model`` section (string
form, or a dict's ``default``/``model`` keys).
Returns:
The parsed reasoning config dict, or None when unset/unrecognized
(caller uses the provider default).
"""
cfg = cfg if isinstance(cfg, dict) else {}
agent_cfg = cfg.get("agent")
if not isinstance(agent_cfg, dict):
agent_cfg = {}
if not model:
model_cfg = cfg.get("model")
if isinstance(model_cfg, str):
model = model_cfg.strip()
elif isinstance(model_cfg, dict):
model = str(
model_cfg.get("default") or model_cfg.get("model") or ""
).strip()
else:
model = ""
overrides = agent_cfg.get("reasoning_overrides") or {}
per_model = resolve_per_model_reasoning_effort(model, overrides)
if per_model is not None:
return per_model
# Global fallback — keep the raw value; coercing with ``or ""`` turns a
# YAML boolean False into "", silently re-enabling thinking for users
# who explicitly disabled it.
effort = agent_cfg.get("reasoning_effort", "")
result = parse_reasoning_effort(effort)
if effort and str(effort).strip() and result is None:
import logging
logging.getLogger(__name__).warning(
"Unknown reasoning_effort '%s', using default (medium)", effort
)
return result
def is_termux() -> bool:
"""Return True when running inside a Termux (Android) environment.

View file

@ -109,3 +109,68 @@ class TestGatewayPerModelReasoningConfig:
result = gateway_run.GatewayRunner._load_reasoning_config()
assert result is not None
assert result.get("enabled") is False
class TestGatewaySessionEffectiveModel:
"""The reasoning override must track the SESSION's effective model.
Regression guard: _load_reasoning_config used to always read
model.default from config.yaml, so a session-only /model switch to a
different model kept resolving the config default's override.
"""
def test_explicit_model_beats_config_default(self, monkeypatch):
"""_load_reasoning_config(model=...) resolves for that model, not model.default."""
fake_cfg = {
"model": {"default": "gpt-5"},
"agent": {
"reasoning_effort": "medium",
"reasoning_overrides": {
"gpt-5": "low",
"claude-opus-4.5": "xhigh",
},
},
}
monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg)
# Session switched (session-only) to claude-opus-4.5 — its override
# must win over the config default model's override.
result = gateway_run.GatewayRunner._load_reasoning_config("claude-opus-4.5")
assert result is not None
assert result["effort"] == "xhigh"
# And without a model arg, the config default's override applies.
result_default = gateway_run.GatewayRunner._load_reasoning_config()
assert result_default is not None
assert result_default["effort"] == "low"
def test_resolve_session_reasoning_forwards_model(self, monkeypatch):
"""_resolve_session_reasoning_config passes the effective model through
(and session-scoped /reasoning overrides still win over it)."""
fake_cfg = {
"model": {"default": "gpt-5"},
"agent": {
"reasoning_effort": "medium",
"reasoning_overrides": {"claude-opus-4.5": "xhigh"},
},
}
monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg)
runner = object.__new__(gateway_run.GatewayRunner)
runner._session_reasoning_overrides = {}
# No session override → per-model override for the effective model.
result = runner._resolve_session_reasoning_config(
session_key="agent:main:telegram:private:1", model="claude-opus-4.5"
)
assert result is not None
assert result["effort"] == "xhigh"
# Session-scoped /reasoning override still wins over per-model.
runner._session_reasoning_overrides = {
"agent:main:telegram:private:1": {"enabled": True, "effort": "minimal"}
}
result = runner._resolve_session_reasoning_config(
session_key="agent:main:telegram:private:1", model="claude-opus-4.5"
)
assert result == {"enabled": True, "effort": "minimal"}

View file

@ -631,6 +631,112 @@ class TestResolvePerModelReasoningEffort:
assert resolve_per_model_reasoning_effort("gemini-2.0-flash", overrides) is None
class TestResolveReasoningConfig:
"""Tests for resolve_reasoning_config() — the single shared chokepoint
every surface (CLI, gateway, TUI, cron, /model switch, fallback) calls.
Contract: per-model override > global agent.reasoning_effort; the raw
global value passes through uncoerced (YAML False = disabled); an
explicit model argument wins over the config's model.default.
"""
def _cfg(self, effort: object = "medium", overrides=None, default_model="gpt-5"):
return {
"model": {"default": default_model},
"agent": {
"reasoning_effort": effort,
"reasoning_overrides": overrides or {},
},
}
def test_per_model_override_wins(self):
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(overrides={"claude-opus-4.5": "xhigh"})
result = resolve_reasoning_config(cfg, "claude-opus-4.5")
assert result == {"enabled": True, "effort": "xhigh"}
def test_global_fallback_when_no_override(self):
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(effort="low", overrides={"claude-opus-4.5": "xhigh"})
assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "low"}
def test_explicit_model_wins_over_config_default(self):
"""The session's effective model (e.g. after a session-only /model
switch) must be used for override lookup NOT model.default."""
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(
effort="medium",
overrides={"gpt-5": "low", "claude-opus-4.5": "xhigh"},
default_model="gpt-5",
)
# Session switched to opus; its override must win over gpt-5's.
result = resolve_reasoning_config(cfg, "claude-opus-4.5")
assert result == {"enabled": True, "effort": "xhigh"}
def test_empty_model_derives_from_config_default(self):
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(overrides={"gpt-5": "high"}, default_model="gpt-5")
assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "high"}
def test_empty_model_derives_from_model_alias_key(self):
"""model: {model: ...} alias shape (older configs) also resolves."""
from hermes_constants import resolve_reasoning_config
cfg = {
"model": {"model": "gpt-5"},
"agent": {"reasoning_effort": "medium", "reasoning_overrides": {"gpt-5": "high"}},
}
assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "high"}
def test_string_model_section(self):
"""Top-level ``model: <string>`` config shape (cron raw-YAML path)."""
from hermes_constants import resolve_reasoning_config
cfg = {
"model": "claude-opus-4.5",
"agent": {"reasoning_effort": "low", "reasoning_overrides": {"claude-opus-4.5": "xhigh"}},
}
assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "xhigh"}
def test_yaml_false_global_uncoerced(self):
"""YAML boolean False must mean disabled — never coerced to ''."""
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(effort=False)
assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": False}
def test_yaml_false_not_shadowed_by_other_models_override(self):
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(effort=False, overrides={"claude-opus-4.5": "xhigh"})
assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": False}
def test_override_none_disables_for_model(self):
"""Per-model override value 'none' disables thinking for that model."""
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(effort="high", overrides={"gemini-flash": "none"})
assert resolve_reasoning_config(cfg, "gemini-flash") == {"enabled": False}
def test_unknown_global_returns_none(self):
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(effort="bogus-level")
assert resolve_reasoning_config(cfg, "gpt-5") is None
def test_empty_config_returns_none(self):
from hermes_constants import resolve_reasoning_config
assert resolve_reasoning_config({}) is None
assert resolve_reasoning_config(None) is None
def test_malformed_sections_tolerated(self):
"""Non-dict agent/model sections must not raise."""
from hermes_constants import resolve_reasoning_config
assert resolve_reasoning_config({"agent": "oops", "model": 42}) is None
assert resolve_reasoning_config({"agent": None, "model": None}) is None
assert resolve_reasoning_config({"agent": {"reasoning_overrides": "bad"}}) is None
def test_invalid_override_value_falls_back_to_global(self):
"""A junk override value for the matching model falls through to global."""
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(effort="medium", overrides={"gpt-5": "turbo-max"})
assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "medium"}
class TestReasoningOverridesDefaultConfig:
"""Tests for the agent.reasoning_overrides default config key (Task 2)."""
@ -640,11 +746,6 @@ class TestReasoningOverridesDefaultConfig:
assert "reasoning_overrides" in DEFAULT_CONFIG["agent"]
assert DEFAULT_CONFIG["agent"]["reasoning_overrides"] == {}
def test_config_version_bumped(self):
"""Config version was bumped to signal the schema change for reasoning_overrides."""
from hermes_cli.config import DEFAULT_CONFIG
assert DEFAULT_CONFIG.get("_config_version") >= 31
def test_load_config_preserves_user_reasoning_overrides(self, tmp_path, monkeypatch):
"""User-added reasoning_overrides are preserved through load_config()."""
import yaml

View file

@ -2562,35 +2562,17 @@ def _display_mouse_tracking(display: dict) -> str:
return "all"
def _load_reasoning_config() -> dict | None:
def _load_reasoning_config(model: str = "") -> dict | None:
"""Load reasoning effort from config.yaml, respecting per-model overrides.
Per-model overrides (agent.reasoning_overrides) take precedence
over the global value when the current model matches a key
(spelling-tolerant). Closes #21256.
Thin wrapper over the shared chokepoint
:func:`hermes_constants.resolve_reasoning_config` (per-model override >
global ``agent.reasoning_effort``; YAML boolean False = disabled).
Closes #21256.
"""
from hermes_constants import parse_reasoning_effort, resolve_per_model_reasoning_effort
from hermes_constants import resolve_reasoning_config
cfg = _load_cfg()
# Per-model override first
model_cfg = cfg.get("model") or {}
model = str(
(model_cfg.get("default", "") if isinstance(model_cfg, dict) else "")
or (model_cfg.get("model", "") if isinstance(model_cfg, dict) else "")
or ""
).strip()
overrides = (cfg.get("agent") or {}).get("reasoning_overrides", {}) or {}
per_model = resolve_per_model_reasoning_effort(model, overrides)
if per_model is not None:
return per_model
# Global fallback — pass the raw value through; ``or ""`` would coerce
# a YAML boolean False (``reasoning_effort: false``/``off``/``no``) to
# "", silently re-enabling thinking for users who explicitly turned it off.
return parse_reasoning_effort(
(cfg.get("agent") or {}).get("reasoning_effort", "")
)
return resolve_reasoning_config(_load_cfg(), model)
def _load_service_tier() -> str | None:
@ -4231,7 +4213,7 @@ def _background_agent_kwargs(agent, task_id: str) -> dict:
"openrouter_min_coding_score": getattr(agent, "openrouter_min_coding_score", None),
"session_id": task_id,
"reasoning_config": getattr(agent, "reasoning_config", None)
or _load_reasoning_config(),
or _load_reasoning_config(str(getattr(agent, "model", "") or "")),
"service_tier": getattr(agent, "service_tier", None) or _load_service_tier(),
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
"platform": "tui",
@ -4660,7 +4642,7 @@ def _make_agent(
reasoning_config=(
reasoning_config_override
if reasoning_config_override is not None
else _load_reasoning_config()
else _load_reasoning_config(str(model or ""))
),
service_tier=(
service_tier_override