Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
39 changes: 38 additions & 1 deletion agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <model-name>",
))
# 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",
Expand Down
102 changes: 102 additions & 0 deletions tests/run_agent/test_primary_runtime_restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Loading