Skip to content
Merged
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
26 changes: 26 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,13 @@ def restore_primary_runtime(agent) -> bool:
primary_provider or "?",
)

# ── Restore reasoning_config if it was saved ──
# switch_model saves reasoning_config in _primary_runtime. If the
# snapshot predates that (older sessions), keep the current value.
saved_reasoning = rt.get("reasoning_config")
if saved_reasoning is not None:
agent.reasoning_config = dict(saved_reasoning)

# ── Reset fallback chain for the new turn ──
agent._fallback_activated = False
agent._fallback_index = 0
Expand Down Expand Up @@ -2065,6 +2072,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
api_mode=agent.api_mode,
)

# ── 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 —
# resolved through the shared chokepoint (per-model > global; YAML
# boolean False = disabled).
try:
from hermes_constants import resolve_reasoning_config
from hermes_cli.config import load_config as _sm_load_config

_reasoning_cfg = _sm_load_config() or {}
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)

# ── Invalidate cached system prompt so it rebuilds next turn ──
agent._cached_system_prompt = None

Expand All @@ -2087,6 +2112,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"client_kwargs": dict(agent._client_kwargs),
"use_prompt_caching": agent._use_prompt_caching,
"use_native_cache_layout": agent._use_native_cache_layout,
"reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None,
"compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model,
"compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url,
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",
Expand Down
22 changes: 22 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
api_mode=agent.api_mode,
)

# Re-resolve reasoning_config for the new fallback model (Closes #21256).
# 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 resolve_reasoning_config

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",
agent.model, _reasoning_err,
)
# Keep whatever reasoning_config was active — don't break the fallback swap.

# Keep the prompt's self-identity in sync with the model actually
# answering, so "what model are you?" doesn't report the primary.
rewrite_prompt_model_identity(agent, fb_model, fb_provider)
Expand Down
13 changes: 13 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,19 @@ agent:
# Options: "xhigh" (max), "high", "medium", "low", "minimal", "none" (disable)
reasoning_effort: "medium"

# Per-model reasoning effort overrides (optional dict)
# Key: any sensible model spelling works (exact, dots↔dashes interchangeable,
# provider prefix optional). First match wins.
# Value: reasoning effort level (same options as reasoning_effort)
# Override the global reasoning_effort for that specific model.
# NOTE: no `hermes config set` support for this key -- edit YAML directly.
# reasoning_overrides:
# "openrouter/anthropic/claude-opus-4.5": "xhigh"
# "openai/gpt-5": "low"
# "claude-opus-4.6": "high" # bare model name also works
# "deepseek/deepseek-v4-pro": "xhigh" # dots and dashes are interchangeable
reasoning_overrides: {}

# Predefined personalities (use with /personality command)
personalities:
helpful: "You are a helpful, friendly AI assistant."
Expand Down
7 changes: 4 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3917,9 +3917,10 @@ def __init__(
)

# Reasoning config (OpenRouter reasoning effort level)
self.reasoning_config = _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", "")
)
Expand Down
11 changes: 6 additions & 5 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2988,11 +2988,12 @@ def run_job(
except Exception:
pass

# Reasoning config from config.yaml (raw value — a YAML boolean False
# means thinking disabled, see parse_reasoning_effort)
from hermes_constants import parse_reasoning_effort
reasoning_config = parse_reasoning_effort(
_cfg.get("agent", {}).get("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)
)

# Prefill messages from env or config.yaml. The top-level
Expand Down
44 changes: 27 additions & 17 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4851,23 +4851,21 @@ def _get_system_prompt_for_channel(
return getattr(self, "_ephemeral_system_prompt", None) or ""

@staticmethod
def _load_reasoning_config() -> dict | None:
"""Load reasoning effort from config.yaml.
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.

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
from hermes_constants import resolve_reasoning_config
cfg = _load_gateway_runtime_config()
# 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]:
Expand Down Expand Up @@ -4900,8 +4898,17 @@ def _resolve_session_reasoning_config(
*,
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:
Expand All @@ -4912,7 +4919,7 @@ def _resolve_session_reasoning_config(
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,
Expand Down Expand Up @@ -13482,7 +13489,9 @@ async def _run_background_task(

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)
Expand Down Expand Up @@ -18233,6 +18242,7 @@ def run_sync():
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()
Expand Down
6 changes: 6 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2658,9 +2658,15 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str:
_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):
Expand Down
9 changes: 8 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1158,8 +1158,15 @@ def _ensure_hermes_home_managed(home: Path):
# only controls how inbound user images are presented.
"image_input_mode": "auto",
"disabled_toolsets": [],

# Per-model reasoning effort overrides (spelling-tolerant).
# Dict mapping model names (any reasonable spelling) to effort levels.
# Takes precedence over agent.reasoning_effort when the current model
# matches a key in this dict.
# Edit directly in config.yaml (no CLI support due to dots in keys).
"reasoning_overrides": {},
},

"terminal": {
"backend": "local",
"modal_mode": "auto",
Expand Down
Loading
Loading