feat: add max reasoning effort level (salvage of #4627)

Salvaged from #4627 by @perejaslav. Core Hermes already supports
reasoning_effort=max; this closes the WebUI parity gap (Anchor-A) — adds
max to VALID_REASONING_EFFORTS + dropdown/chip/reasoning-command, keeps
the degrade ladder for capped models.

Co-authored-by: perejaslav <perejaslav@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-07-11 01:13:36 +00:00
parent 91804b30fc
commit d56123f796
7 changed files with 19 additions and 30 deletions

View file

@ -3026,7 +3026,7 @@ def get_effective_default_model(config_data: dict | None = None) -> str:
# importing from the agent tree (which may not be installed). Any drift here
# will show up in the shared test suite since both sides accept the same set.
# Keep this WebUI-visible set aligned with hermes-agent#29248.
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")
def parse_reasoning_effort(effort):
@ -3631,9 +3631,6 @@ def coerce_reasoning_effort_for_model(
return ""
if raw == "none":
return "none"
accepts_max_as_xhigh = raw == "max"
if accepts_max_as_xhigh:
raw = "xhigh"
if raw not in VALID_REASONING_EFFORTS:
return ""
supported = resolve_model_reasoning_efforts(
@ -3649,17 +3646,15 @@ def coerce_reasoning_effort_for_model(
# those paths return a NON-empty clamped set, so the degrade ladder below
# still applies. When the set is empty we can't tell "unsupported" from
# "unknown", so preserve the user's configured effort verbatim where it is
# still valid. A stale 'max' value is no longer parser-valid on the WebUI
# side, so degrade that unknown-model case to xhigh instead of silently
# dropping reasoning later in parse_reasoning_effort(). (#3505 review)
# still valid. (#3505 review)
if not supported:
return "xhigh" if accepts_max_as_xhigh else raw
return raw
if raw in supported:
return "xhigh" if accepts_max_as_xhigh else raw
return raw
# Degrade to the closest *lower* supported level instead of silently
# disabling reasoning. e.g. max -> xhigh -> high, or xhigh -> high when the
# target model caps below the configured effort. Never escalate.
ladder = list(VALID_REASONING_EFFORTS) # ascending: minimal..xhigh
ladder = list(VALID_REASONING_EFFORTS) # ascending: minimal..xhigh..max
try:
raw_idx = ladder.index(raw)
except ValueError:
@ -3712,10 +3707,8 @@ def get_reasoning_status(
return {
# Match CLI default (True if unset in config.yaml)
"show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True,
# Report the COERCED effort (not the raw config value) so boot/status/chip
# read paths agree with what streaming actually sends — e.g. a stale
# `reasoning_effort: max` surfaces as `xhigh`, not the now-unsupported `max`.
# (Codex review of the drop-max alignment.)
# Report the COERCED effort so boot/status/chip read paths agree with
# what streaming actually sends. (Codex review of the drop-max alignment.)
"reasoning_effort": coerce_reasoning_effort_for_model(
str(effort_raw or "").strip().lower(),
resolve_model,
@ -3834,7 +3827,7 @@ def set_reasoning_effort(
"""Persist ``agent.reasoning_effort`` to the active profile's config.yaml.
Mirrors CLI ``/reasoning <level>``: same key, same valid values
(``none`` | ``minimal`` | ``low`` | ``medium`` | ``high`` | ``xhigh``).
(``none`` | ``minimal`` | ``low`` | ``medium`` | ``high`` | ``xhigh`` | ``max``).
Raises ``ValueError`` on an unrecognised level so callers can return 400.
"""
raw = str(effort or "").strip().lower()

View file

@ -30,7 +30,7 @@ const COMMANDS=[
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh|max', subArgs:['show','hide','none','minimal','low','medium','high','xhigh','max'], noEcho:true},
{name:'yolo', desc:t('cmd_yolo'), fn:cmdYolo, noEcho:true},
{name:'branch', desc:t('cmd_branch'), fn:cmdBranch, arg:'[name]', noEcho:true},
];
@ -1799,13 +1799,13 @@ function cmdReasoning(args){
const BRAIN='\uD83E\uDDE0';
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
// Keep this WebUI effort list in sync with hermes-agent#29248.
const EFFORTS=['none','minimal','low','medium','high','xhigh'];
const EFFORTS=['none','minimal','low','medium','high','xhigh','max'];
// Shared status renderer used by the no-args branch and as a fallback.
function _fmtStatus(st){
const vis=(st && st.show_reasoning===false)?'off':'on';
const eff=(st && st.reasoning_effort)||'default';
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh';
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh|max';
}
if(!arg){
// Status — read from the same config.yaml keys the CLI uses.

View file

@ -759,6 +759,7 @@
<div class="reasoning-option" data-effort="medium">Medium</div>
<div class="reasoning-option" data-effort="high">High</div>
<div class="reasoning-option" data-effort="xhigh">Extra High</div>
<div class="reasoning-option" data-effort="max">Max</div>
</div>
<div class="composer-toolsets-dropdown" id="composerToolsetsDropdown">
<div class="toolsets-dropdown-desc" id="toolsetsDropdownDesc"></div>

View file

@ -4481,6 +4481,7 @@ function _formatReasoningEffortLabel(effort){
if(effort==='medium') return 'Medium';
if(effort==='high') return 'High';
if(effort==='xhigh') return 'XHigh';
if(effort==='max') return 'Max';
return effort.charAt(0).toUpperCase()+effort.slice(1);
}

View file

@ -45,8 +45,8 @@ def test_reasoning_chip_html_starts_hidden():
src
)
assert m, "composerReasoningWrap must start with style='display:none'"
assert 'data-effort="max"' not in src, (
"composer reasoning dropdown must not include Max"
assert 'data-effort="max"' in src, (
"composer reasoning dropdown must include Max option"
)

View file

@ -83,7 +83,7 @@ def test_coerce_preserves_effort_for_unrecognized_model():
"max",
"brand-new-model-2099",
provider_id="some-custom-provider",
) == "xhigh"
) == "max"
# 'none' / unset still pass through unchanged for unknown models.
assert cfg.coerce_reasoning_effort_for_model(
"none", "some-unknown-model-xyz", provider_id="custom"

View file

@ -312,14 +312,11 @@ class TestReasoningCommand:
m = re.search(r'function cmdReasoning\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
for level in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh'):
for level in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'):
assert f"'{level}'" in fn, (
f"cmdReasoning must accept '{level}' (CLI parity with "
f"hermes_constants.parse_reasoning_effort)"
)
assert "'max'" not in fn, (
"cmdReasoning should only expose efforts up to xhigh"
)
def test_reasoning_subargs_match_cli_levels(self):
"""Autocomplete subArgs must expose every CLI effort level + show/hide."""
@ -328,14 +325,11 @@ class TestReasoningCommand:
assert m, "reasoning COMMANDS entry not found"
entry = m.group(0)
for suggestion in (
'show', 'hide', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh'
'show', 'hide', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'
):
assert f"'{suggestion}'" in entry, (
f"reasoning subArgs must include '{suggestion}' for CLI parity"
)
assert "'max'" not in entry, (
"reasoning command subArgs must not include 'max'"
)
# ── api/config.py — reasoning helpers ────────────────────────────────────────
@ -365,7 +359,7 @@ class TestReasoningConfigHelpers:
# Snapshot-style assertion: if hermes_constants adds a level, this
# test will fail fast so we know to update WebUI too.
assert VALID_REASONING_EFFORTS == (
'minimal', 'low', 'medium', 'high', 'xhigh'
'minimal', 'low', 'medium', 'high', 'xhigh', 'max'
)
def test_set_reasoning_effort_persists_to_config_yaml(self, tmp_path, monkeypatch):