diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 28f0192d8161..c6ed459e93d9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -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) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 57cc5e76cdb8..9a4bc8a1fe6c 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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", diff --git a/cli.py b/cli.py index c836834a2169..25cce4f95d05 100644 --- a/cli.py +++ b/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", "") ) diff --git a/cron/scheduler.py b/cron/scheduler.py index 5e80fc2d7aec..176561c89f70 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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 diff --git a/docs/PER_MODEL_REASONING.md b/docs/PER_MODEL_REASONING.md deleted file mode 100644 index 14a89466d6ee..000000000000 --- a/docs/PER_MODEL_REASONING.md +++ /dev/null @@ -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.` 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 diff --git a/gateway/run.py b/gateway/run.py index 7ede7d4130df..3bea0eb4034b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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() diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 765369f1c5e6..731ec0d1781c 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -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): diff --git a/hermes_constants.py b/hermes_constants.py index 5d64a619ecaa..6e344844812d 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -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. diff --git a/tests/gateway/test_reasoning_config_per_model.py b/tests/gateway/test_reasoning_config_per_model.py index f9799de33198..ffd6ba0b9444 100644 --- a/tests/gateway/test_reasoning_config_per_model.py +++ b/tests/gateway/test_reasoning_config_per_model.py @@ -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"} diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 5c70ebcb952b..8eddceb70eaf 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -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: `` 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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index db32955ddd90..af5cead103c1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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