mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
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:
parent
d9cdb81923
commit
e81d18dfb4
11 changed files with 302 additions and 253 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
17
cli.py
|
|
@ -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", "")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue