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
47 changes: 47 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,45 @@ 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.
# 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.
try:
from hermes_constants import (
parse_reasoning_effort,
resolve_per_model_reasoning_effort,
)
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)")
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 +2133,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
39 changes: 39 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,45 @@ 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).
# Per-model override (if any) takes precedence, else global reasoning_effort.
# Wrapped in try/except because 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

_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,
)
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
14 changes: 12 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3917,8 +3917,18 @@ def __init__(
)

# Reasoning config (OpenRouter reasoning effort level)
self.reasoning_config = _parse_reasoning_config(
CLI_CONFIG["agent"].get("reasoning_effort", "")
# 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", "")
)
)
self.service_tier = _parse_service_tier_config(
CLI_CONFIG["agent"].get("service_tier", "")
Expand Down
23 changes: 18 additions & 5 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2922,12 +2922,25 @@ 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)
from hermes_constants import (
parse_reasoning_effort,
resolve_per_model_reasoning_effort,
)
_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
Expand Down
101 changes: 101 additions & 0 deletions docs/PER_MODEL_REASONING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# 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
26 changes: 21 additions & 5 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4852,17 +4852,33 @@ def _get_system_prompt_for_channel(

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

Per-model overrides (agent.reasoning_overrides) take precedence
over the global value when the current model matches a key
(spelling-tolerant). Closes #21256.
"""
from hermes_constants import parse_reasoning_effort
from hermes_constants import parse_reasoning_effort, resolve_per_model_reasoning_effort
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.
# 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:
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