diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1cde73419a449..47d5fe8ac1872 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1007,6 +1007,10 @@ def try_recover_primary_transport( if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] + # Restore the primary's reasoning effort if a fallback entry + # overrode it (#21256). Guarded for snapshots predating the key. + if "reasoning_config" in rt: + agent.reasoning_config = rt["reasoning_config"] if agent.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client @@ -1172,6 +1176,12 @@ def restore_primary_runtime(agent) -> bool: agent.api_key = rt["api_key"] agent._client_kwargs = dict(rt["client_kwargs"]) agent._use_prompt_caching = rt["use_prompt_caching"] + # Restore the primary's reasoning effort (a fallback entry may have + # overridden it via reasoning_effort). Older snapshots predate this + # key — only restore when it was captured, to avoid clobbering the + # live value with None. See #21256. + if "reasoning_config" in rt: + agent.reasoning_config = rt["reasoning_config"] # Default to native layout when the restored snapshot predates the # native-vs-proxy split (older sessions saved before this PR). agent._use_native_cache_layout = rt.get( @@ -2094,6 +2104,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "compressor_context_length": _cc.context_length if _cc else 0, "compressor_api_mode": getattr(_cc, "api_mode", agent.api_mode) if _cc else agent.api_mode, "compressor_threshold_tokens": _cc.threshold_tokens if _cc else 0, + # Snapshot the primary's reasoning effort so a fallback entry that + # overrides it (fallback_model[N].reasoning_effort) can be reverted + # when the primary is restored. See per-entry reasoning support in + # try_activate_fallback (#21256). + "reasoning_config": getattr(agent, "reasoning_config", None), } if api_mode == "anthropic_messages": agent._primary_runtime.update({ diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index b93dcf661717f..f6115cc2eaad6 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1511,12 +1511,49 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool agent._transport_cache.clear() agent._fallback_activated = True + # Per-entry reasoning effort override (#21256). A fallback_model + # entry may carry ``reasoning_effort`` (none through ultra) to run that + # tier at a different thinking depth than the + # global ``agent.reasoning_effort``. The primary's reasoning_config is + # snapshotted in ``_primary_runtime`` and restored on primary recovery, + # so this only affects the fallback turn(s). Absent/blank keeps the + # current reasoning effort unchanged. + _fb_reasoning_effort = str(fb.get("reasoning_effort") or "").strip() + if _fb_reasoning_effort: + try: + from hermes_constants import parse_reasoning_effort + + _fb_reasoning_config = parse_reasoning_effort(_fb_reasoning_effort) + if _fb_reasoning_config is not None: + agent.reasoning_config = _fb_reasoning_config + logger.info( + "Fallback %s/%s: reasoning effort override → %s", + fb_provider, + fb_model, + _fb_reasoning_effort, + ) + else: + logger.warning( + "Fallback %s/%s: unknown reasoning_effort '%s' — " + "keeping current effort", + fb_provider, + fb_model, + _fb_reasoning_effort, + ) + except Exception as _re_exc: # noqa: BLE001 + logger.warning( + "Fallback %s/%s: failed to apply reasoning_effort '%s': %s", + fb_provider, + fb_model, + _fb_reasoning_effort, + _re_exc, + ) + # Rebind the credential pool to the fallback provider when the provider # changes. Keeping the primary pool attached would make downstream # recovery (rate_limit / billing / auth) mutate the wrong credential # set and can overwrite the fallback's base_url back to the primary # endpoint. See #33163. - # # When the fallback shares the pool's provider (e.g. both openrouter # entries with different routing) the pool is preserved. When the # providers differ, load the fallback provider's own pool if one exists diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 03570bceba498..6da5df05f1bd7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5327,6 +5327,19 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List[" f"fallback_model[{i}] is missing 'model' field", "Add: model: ", )) + # Optional per-entry reasoning_effort override (#21256). + _re = entry.get("reasoning_effort") + if _re is not None and str(_re).strip(): + from hermes_constants import VALID_REASONING_EFFORTS, parse_reasoning_effort + + if parse_reasoning_effort(_re) is None: + _valid = ", ".join(("none", *VALID_REASONING_EFFORTS)) + issues.append(ConfigIssue( + "warning", + f"fallback_model[{i}] has invalid reasoning_effort " + f"'{_re}' — ignored (global effort used)", + f"Use one of: {_valid}", + )) elif not isinstance(fb, dict): issues.append(ConfigIssue( "error", diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index 0d732277e977f..b7170dd2b8ea9 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -731,3 +731,105 @@ def test_cooldown_not_set_when_already_on_fallback(self): # second call should not have extended the cooldown assert second_cooldown == first_cooldown + + +# ============================================================================= +# Per-entry reasoning_effort override (#21256) +# ============================================================================= + +class TestFallbackReasoningEffort: + """A fallback_model entry may carry reasoning_effort to run that tier at a + different thinking depth than the global agent.reasoning_effort, restored + when the primary comes back.""" + + def test_override_applied_on_fallback(self): + agent = _make_agent( + fallback_model={ + "provider": "openai-codex", + "model": "gpt-5.5", + "reasoning_effort": "xhigh", + }, + ) + # Primary starts with no/medium reasoning override. + agent.reasoning_config = None + + mock_client = _mock_resolve() + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)): + assert agent._try_activate_fallback() is True + + # The fallback entry's xhigh effort is now active. + assert agent.reasoning_config == {"enabled": True, "effort": "xhigh"} + + def test_override_restored_on_primary(self): + agent = _make_agent( + fallback_model={ + "provider": "openai-codex", + "model": "gpt-5.5", + "reasoning_effort": "xhigh", + }, + ) + # Primary runs at an explicit medium effort. Set it on both the live + # agent and the captured _primary_runtime snapshot (init captured the + # value present at construction time). + primary_cfg = {"enabled": True, "effort": "medium"} + agent.reasoning_config = primary_cfg + agent._primary_runtime["reasoning_config"] = primary_cfg + + mock_client = _mock_resolve() + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)): + agent._try_activate_fallback() + assert agent.reasoning_config == {"enabled": True, "effort": "xhigh"} + + # Restoring the primary brings back medium. + with patch("run_agent.OpenAI", return_value=MagicMock()): + assert agent._restore_primary_runtime() is True + assert agent.reasoning_config == primary_cfg + + def test_absent_key_leaves_reasoning_unchanged(self): + agent = _make_agent( + fallback_model={"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}, + ) + primary_cfg = {"enabled": True, "effort": "high"} + agent.reasoning_config = primary_cfg + + mock_client = _mock_resolve() + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)): + agent._try_activate_fallback() + + # No reasoning_effort on the entry → keep whatever was active. + assert agent.reasoning_config == primary_cfg + + def test_invalid_level_ignored(self): + agent = _make_agent( + fallback_model={ + "provider": "openrouter", + "model": "model-x", + "reasoning_effort": "extreme", # not a valid level + }, + ) + primary_cfg = {"enabled": True, "effort": "medium"} + agent.reasoning_config = primary_cfg + + mock_client = _mock_resolve() + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)): + agent._try_activate_fallback() + + # Unknown level → no override, primary effort preserved. + assert agent.reasoning_config == primary_cfg + + def test_none_disables_reasoning_on_fallback(self): + agent = _make_agent( + fallback_model={ + "provider": "openrouter", + "model": "fast-model", + "reasoning_effort": "none", + }, + ) + agent.reasoning_config = {"enabled": True, "effort": "high"} + + mock_client = _mock_resolve() + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)): + agent._try_activate_fallback() + + # "none" → reasoning explicitly disabled for this tier. + assert agent.reasoning_config == {"enabled": False}