Conversation
Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but
hemes-webui advertised the full 6-level ladder for all 7 GLM models because
_candidate_supports_reasoning has an unconditional glm token match and
_filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog
models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a
selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking
that cannot be disabled per Z.AI docs) showed a 'none' option with no effect.
Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing
OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM
models and for the forced-thinking GLM-4.7 family; preserve the full ladder for
GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate
is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all
resolve to zai); aggregator providers are untouched because they route through
their own routers, not Z.AI's native endpoint.
The glm family-detection heuristic in _candidate_supports_reasoning is unchanged
— GLM models DO support the thinking on/off toggle at the family level; this fix
is specifically about the reasoning_effort intensity ladder.
State layer: agent.reasoning_effort config + UI dropdown options derived from
resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree
and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never
offered for forced-thinking models). Out of scope: the thinking:{type:...}
request-field translation lives in the external agent/gateway layer.
|
| Filename | Overview |
|---|---|
| api/config.py | Adds three new helper functions (_zai_glm_classification, _zai_glm_reasoning_efforts_supported, _zai_glm_thinking_toggle_supported), wires ZAI gate into _filter_reasoning_efforts_for_provider and coerce_reasoning_effort_for_model, adds forced-thinking early exit in resolve_model_reasoning_efforts, and extends get_reasoning_status with supports_thinking_toggle. The two previously-flagged coercion gaps are both addressed. |
| static/ui.js | Adds _currentReasoningToggleSupported state variable, updates chip visibility logic to OR effort ladder with thinking toggle, always surfaces Default (effort='') and None in the dropdown, and fixes the click handler to use opt presence rather than effort truthiness. The undefined→true default introduces a subtle behavioral change from the old code. |
| static/index.html | Adds a 'Default' option (data-effort='') as the first item in the reasoning dropdown, enabling the two-way thinking toggle for GLM-4.5–5.1 models. |
| tests/test_zai_reasoning_effort_gating.py | New test file with 24 parametrized tests covering the three-tier classification, alias resolution, aggregator passthrough, coercion agreement, and set_reasoning_effort empty-accept path. |
| tests/test_reasoning_chip_js_behaviour.py | Adds two new test classes (TestSupportsThinkingToggleVisibility, TestTwoStateToggleControl) that drive the actual ui.js functions through Node.js sub-processes, verifying chip visibility and two-way toggle option rendering across all three GLM tiers. |
| tests/test_reasoning_show_hide.py | Updates test_set_reasoning_effort_rejects_invalid to reflect the new accepted-empty contract; removes the assertion that set_reasoning_effort('') raises ValueError and adds a positive test that it completes without error. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["Model + Provider ID"] --> B["_resolve_provider_alias()"]
B --> C{"provider == 'zai'?"}
C -- No --> D["Other provider rules\n(OpenAI ceiling, Gemini, Anthropic)"]
C -- Yes --> E{"'glm' in bare id?"}
E -- No --> D
E -- Yes --> F{"bare.startswith('glm-4.7')?"}
F -- Yes --> G["'forced'\nNeither toggle nor ladder"]
F -- No --> H["re.search version\nmajor, minor"]
H --> I{"(major,minor) >= (5,2)?"}
I -- Yes --> J["'effort'\nFull ladder + toggle\nGLM-5.2+"]
I -- No --> K{"(major,minor) >= (4,5)?"}
K -- Yes --> L["'thinking'\nToggle only, no ladder\nGLM-4.5-5.1"]
K -- No --> M["None\nNo thinking support\nGLM-4 and below"]
J --> N["resolve_model_reasoning_efforts\n-> [minimal..max]"]
L --> O["resolve_model_reasoning_efforts\n-> []"]
G --> P["resolve_model_reasoning_efforts\n-> [] (forced exit)"]
N --> Q["get_reasoning_status\nsupports_thinking_toggle: true"]
O --> R["get_reasoning_status\nsupports_thinking_toggle: true"]
P --> S["get_reasoning_status\nsupports_thinking_toggle: false"]
Q --> T["UI: Full effort dropdown"]
R --> U["UI: Default + None only"]
S --> V["UI: Chip hidden"]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["Model + Provider ID"] --> B["_resolve_provider_alias()"]
B --> C{"provider == 'zai'?"}
C -- No --> D["Other provider rules\n(OpenAI ceiling, Gemini, Anthropic)"]
C -- Yes --> E{"'glm' in bare id?"}
E -- No --> D
E -- Yes --> F{"bare.startswith('glm-4.7')?"}
F -- Yes --> G["'forced'\nNeither toggle nor ladder"]
F -- No --> H["re.search version\nmajor, minor"]
H --> I{"(major,minor) >= (5,2)?"}
I -- Yes --> J["'effort'\nFull ladder + toggle\nGLM-5.2+"]
I -- No --> K{"(major,minor) >= (4,5)?"}
K -- Yes --> L["'thinking'\nToggle only, no ladder\nGLM-4.5-5.1"]
K -- No --> M["None\nNo thinking support\nGLM-4 and below"]
J --> N["resolve_model_reasoning_efforts\n-> [minimal..max]"]
L --> O["resolve_model_reasoning_efforts\n-> []"]
G --> P["resolve_model_reasoning_efforts\n-> [] (forced exit)"]
N --> Q["get_reasoning_status\nsupports_thinking_toggle: true"]
O --> R["get_reasoning_status\nsupports_thinking_toggle: true"]
P --> S["get_reasoning_status\nsupports_thinking_toggle: false"]
Q --> T["UI: Full effort dropdown"]
R --> U["UI: Default + None only"]
S --> V["UI: Chip hidden"]
Reviews (4): Last reviewed commit: "fix(reasoning): make ZAI thinking toggle..." | Re-trigger Greptile
Address Greptile review on nesquena#6219 (round 1): 1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or' fallback made the assertion always-true, so a regression stripping 'none' for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source (mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the test now genuinely exercises the preservation branch. 2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing 'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no rule' (preserving the configured effort verbatim per nesquena#3505), so a stored 'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded to Z.AI unchanged and silently ignored — contradicting the PR's stated UI/coercion agreement invariant. Root cause: the ZAI gate returns [] to mean 'known-empty' (no reasoning_effort at all), but the coercion path treated all [] as 'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by both the filter and coercion, then special-casing the known-False result in coerce_reasoning_effort_for_model to return '' (send no field). The nesquena#3505 preserve-verbatim behavior for genuinely-unknown models on non-zai providers is unchanged. Added 10 regression tests (all fail before the coercion fix, pass after): - All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7 - All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate - GLM-5.2 preserves all 6 levels verbatim - Regression guard: unknown model on custom: provider STILL preserves verbatim State layer: agent.reasoning_effort config + the value forwarded to Z.AI. Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion sends no reasoning_effort field for any stored level on those models.
|
Thanks @greptile-apps[bot] for the review — both P2 findings were real and are addressed in 1. Vacuous test assertion — fixed
Rewrote to inject 2. Coercion gap for non-
|
|
Great catch on the real problem — the WebUI genuinely does over-advertise the SILENT — sub-5.2 GLM users lose the working thinking on/off control —
|
nesquena-hermes
left a comment
There was a problem hiding this comment.
Version-parsing solid, but returning [] efforts for sub-5.2 GLM hides the whole reasoning chip incl the thinking on/off toggle that GLM-4.5+ DOES support. Fix: separate thinking-toggle capability from supported_efforts (keep ladder empty <5.2 but retain On/None for 4.5+ except forced-thinking 4.7). Fix-spec posted. Re-gate on re-push.
…ladder empty Address nesquena-hermes round-2 review on nesquena#6219: returning [] for the effort ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer (static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/ 5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking {type:enabled|disabled} toggle even though they do not accept the reasoning_effort intensity ladder. Fix decouples thinking-toggle capability from the effort ladder: Backend (api/config.py): - Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking' (GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None (non-zai / non-GLM). Single source of truth shared by all three consumers. - _zai_glm_reasoning_efforts_supported now wraps classification for the coercion contract (unchanged behavior). - _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking', False for 'forced', None otherwise. - get_reasoning_status gains a supports_thinking_toggle field = bool(supported) OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts) so their chip-visibility behavior is unchanged. Frontend (static/ui.js): - New _currentReasoningToggleSupported state var (default undefined = treat as true so legacy responses without the field do not newly hide the chip). - _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported. Empty efforts + toggle=True keeps the chip visible with just the None/On control (the existing _applyReasoningOptions already shows 'none' when the ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it. - Profile-transition and fetch-failure resets now pass supports_thinking_toggle:false alongside the empty efforts so the chip hides during the unknown-state window, matching the prior reset contract. Tests (30 new): - _zai_glm_classification parametrized across all three tiers + aliases + defer - get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6 toggle-only, GLM-4.7 neither, non-zai defaults to effort capability) - Frontend _applyReasoningChip behavior via node driver: empty efforts + toggle=True stays visible, toggle=False hides, effort ladder alone is sufficient, absent field keeps prior behavior State layer: agent.reasoning_effort config + supports_thinking_toggle field in /api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY when the model supports neither the effort ladder nor the thinking toggle (GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the working On/None control they had before the round-1 effort gate.
|
Great catch — this was a real silent regression I had missed. The round-1 gate returned Fix — decoupled thinking-toggle capability from the effort ladderBackend (
|
| Model | effort ladder | thinking toggle | chip |
|---|---|---|---|
glm-5.2 / glm-5.3 / glm-6 |
full 6 levels | yes | full dropdown |
glm-5.1 / glm-5 / glm-5-turbo / glm-4.6 / glm-4.5 / glm-4.5-flash / glm-4.5-air |
empty | yes | On/None only (the previously-working control, preserved) |
glm-4.7 / glm-4.7-air |
empty | no | hidden (forced thinking) |
Tests (30 new)
_zai_glm_classificationparametrized across all three tiers + aliases (glm/z-ai/z.ai/zhipuresolve through the same gate) + non-zai/non-GLM defer cases.get_reasoning_statussupports_thinking_toggleper tier — including the exact scenarios you asked for: GLM-4.6 → no ladder BUT thinking On/None available; GLM-4.7 → forced (no off); GLM-5.2 → full ladder + none. Plus GLM-4.5-flash/5/5.1/5-turbo, all four aliases, and a regression guard that non-zai providers default tobool(supported_efforts)(no spurious toggle when ZAI gate doesn't fire).- Frontend
_applyReasoningChipbehavior via the existing node driver (extended to passmeta): empty efforts +toggle=truestays visible,toggle=falsehides, effort ladder alone is sufficient regardless of toggle flag, absent field keeps prior behavior.
Regression gate
The three-tier classification is structurally new in this round (the helper, the field, and the frontend flag did not exist before), so the per-tier status assertions cannot pass without the fix. The pre-existing chip suite (test_reasoning_chip_js_behaviour, test_reasoning_chip_btw_fixes, test_issue1103_reasoning_chip_visibility, test_issue4650_reasoning_chip_no_storm) all still pass — 190 passed, 1 skipped across the full reasoning + chip surface.
Re-push is in; ready for re-gate.
Round-2 re-gate — visibility regression fixed, but the toggle is now one-wayRe-reviewing against my round-1 ask (separate the thinking on/off capability from the effort ladder so returning The three-tier split is right and the visibility fix lands. const hasEffortLadder=Array.isArray(supportedEfforts)?supportedEfforts.length>0:true;
const supports=hasEffortLadder||toggleSupported; // ui.js:4941-4943The node-driven The residual gap — you can turn thinking OFF but not back ONFor a thinking-toggle model the chip is now visible, but the dropdown is populated by dd.querySelectorAll('.reasoning-option').forEach(function(opt){
const effort=opt.dataset.effort;
if(effort==='none'){ opt.style.display=''; return; } // ui.js:4896-4899
if(!supported.size){ opt.style.display='none'; return; } // everything else hidden
...
});The dropdown HTML has no selectable "Default"/"On" entry — only RecommendationGive thinking-toggle models a real two-state control. Minimal approach: when function _applyReasoningOptions(supportedEfforts, toggleOnly){
// toggleOnly (empty ladder + thinking toggle): show only Default(on) + None
...
}Plus a Everything else here is ready to merge once the re-enable path exists. Nice work isolating the three tiers cleanly. |
|
Good progress — the whole-chip-hidden regression is closed (the tri-state SILENT — the thinking toggle is ONE-WAY for GLM-4.5/4.6/5.0/5.1 —
|
nesquena-hermes
left a comment
There was a problem hiding this comment.
Round 2: whole-chip-hidden regression closed + version-parse re-verified clean, but 2 SILENT gaps remain: (1) ui.js:4902 thinking is ONE-WAY for 4.5-5.1 (only 'none' option, no On/Default — can't re-enable; backend rejects empty effort); (2) config.py:3984/3857 GLM-4.7 not forced when 'none' already stored (streaming builds disabled reasoning + supported_efforts=['none']). Fix-spec posted.
…ed none Address nesquena-hermes round-3 review on nesquena#6219 — two SILENT gaps in the thinking-toggle path, plus a click-handler sibling I found while auditing. Gap nesquena#1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247, static/ui.js:4902, static/index.html:757): The round-2 fix kept the chip visible for thinking-tier models but the only rendered dropdown option was 'None' (the HTML had no Default option, and set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could turn thinking OFF but never back ON — worse than the original bug. Fix: - set_reasoning_effort now accepts empty effort as 'clear the override' (removes agent.reasoning_effort so the provider default takes effect). Invalid values still raise ValueError. - static/index.html gains a <div data-effort=''>Default</div> option. - _applyReasoningOptions always shows both Default ('') and None alongside the effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders an operable Default+None two-state control. - Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness (if(effort)) — the old check silently ignored data-effort='' clicks, which would have left the Default button dead even after the HTML/backend changes. Gap nesquena#2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857): When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved 'none' via the early return at line 3985, so streaming built disabled reasoning for a model that forces thinking on regardless. Separately, when the raw capability source listed 'none', resolve_model_reasoning_efforts reattached it to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI for a forced-thinking model. Fix: - coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced' BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default = thinking on) for forced models. - resolve_model_reasoning_efforts returns [] early for forced-tier models, skipping the 'none' reattachment entirely. Tests (17 new): - Gap nesquena#2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM still accepts 'none'; resolve does not reattach 'none' for forced but DOES for thinking-tier; end-to-end get_reasoning_status for forced+stored-none. - Gap nesquena#1 backend: set('') clears the key (no raise), set('garbage') still raises, all 7 valid levels still save. - Gap nesquena#1 frontend: three new node-driver tests asserting the dropdown exposes both Default and None for thinking-tier (two-state), Default+None+ladder for effort-tier, and the off->on->off round trip keeps both visible throughout. - Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to reflect the new contract (empty accepted, garbage rejected). Regression gate: gap#2 coerce tests fail without the forced-tier check (3 failures); gap#1 backend test fails with 'ValueError: effort is required' without the empty-acceptance change. 219 passed, 1 pre-existing skip across the full reasoning + chip + config-cache surface. State layer: agent.reasoning_effort config + supports_thinking_toggle field + composer dropdown options. Invariant: the thinking toggle is now genuinely two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve, status, or UI).
|
Both SILENT gaps closed in Gap #1 — One-way toggle fixed (the thinking tier can now re-enable)Three coordinated changes so GLM-4.5/4.6/5.0/5.1 get a real two-state Default+None control: Backend ( Frontend ( Click handler sibling ( Gap #2 — GLM-4.7 forced + stored
|
| Model | coerce(none) | supported_efforts | status reasoning_effort | toggle |
|---|---|---|---|---|
| glm-5.2 | none |
full ladder | none |
true |
| glm-4.6 | none |
[] (or ["none"] if raw source lists it) |
none |
true |
| glm-4.7 | "" |
[] |
"" (default = on) |
false |
| glm-4.7-air | "" |
[] |
"" |
false |
Tests (17 new + 1 updated)
- Gap Hermes Web UI — Sprints 11-14: multi-provider models, settings, sessi… #2 (6 new):
coerce('none', glm-4.7)→''× glm-4.7 + glm-4.7-air; regression guard that glm-5.2/4.6/5.1 still acceptnone; resolver does NOT reattachnonefor forced but DOES for thinking-tier; end-to-endget_reasoning_statusfor forced + storednone. - Gap Portability #1 backend (3 new + 1 updated):
set('')clears the key without raising;set('garbage')still raises; all 7 valid levels still save. Updatedtest_reasoning_show_hide.test_set_reasoning_effort_rejects_invalidto reflect the new contract (empty accepted, garbage rejected). - Gap Portability #1 frontend (3 new): node-driver tests asserting the dropdown exposes both Default and None for thinking-tier (two-state), Default+None+ladder for effort-tier, and the off→on→off round trip keeps both options visible at every step.
Regression gate (per guideline #6)
- Gap Hermes Web UI — Sprints 11-14: multi-provider models, settings, sessi… #2 coerce: 3 tests fail without the forced-tier check (
coerce_stored_none_to_empty_for_forced_glm[glm-4.7/-air]+get_reasoning_status_forced_glm_with_stored_none_reports_default). - Gap Portability #1 backend:
test_set_reasoning_effort_accepts_empty_as_clearfails withValueError: effort is requiredwithout the empty-acceptance change. - Gap Portability #1 frontend: the Default option and the
if(opt)click guard are structurally new — the two-state dropdown tests cannot pass without them.
219 passed, 1 pre-existing skip across the full reasoning + chip + config-cache surface (test_zai_reasoning_effort_gating, test_reasoning_chip_js_behaviour, test_reasoning_chip_btw_fixes, test_issue1103_reasoning_chip_visibility, test_issue4650_reasoning_chip_no_storm, test_reasoning_effort_model_capabilities, test_reasoning_show_hide, test_models_dev_reasoning, test_issue3750_lmstudio_probe_auth, test_issue4650_yaml_config_cache, test_issue3958_reasoning_post_session_context). ui.js syntax valid (node --check). No new lint errors.
Out of scope (noted, not changed)
The CLI /reasoning slash command (static/commands.js) does not expose a default alias — its EFFORTS list is none/minimal/low/medium/high/xhigh/max. The status display already maps empty effort to 'default', so the concept exists CLI-side. Adding a /reasoning default alias would give the CLI parity with the new WebUI Default button, but it's a separate enhancement and outside this bug fix's scope.
Re-push is in; ready for re-gate.
* fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai
Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but
hemes-webui advertised the full 6-level ladder for all 7 GLM models because
_candidate_supports_reasoning has an unconditional glm token match and
_filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog
models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a
selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking
that cannot be disabled per Z.AI docs) showed a 'none' option with no effect.
Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing
OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM
models and for the forced-thinking GLM-4.7 family; preserve the full ladder for
GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate
is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all
resolve to zai); aggregator providers are untouched because they route through
their own routers, not Z.AI's native endpoint.
The glm family-detection heuristic in _candidate_supports_reasoning is unchanged
— GLM models DO support the thinking on/off toggle at the family level; this fix
is specifically about the reasoning_effort intensity ladder.
State layer: agent.reasoning_effort config + UI dropdown options derived from
resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree
and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never
offered for forced-thinking models). Out of scope: the thinking:{type:...}
request-field translation lives in the external agent/gateway layer.
* fix(reasoning): close ZAI coercion gap + harden test assertions
Address Greptile review on #6219 (round 1):
1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or'
fallback made the assertion always-true, so a regression stripping 'none'
for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source
(mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the
test now genuinely exercises the preservation branch.
2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing
'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no
rule' (preserving the configured effort verbatim per #3505), so a stored
'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded
to Z.AI unchanged and silently ignored — contradicting the PR's stated
UI/coercion agreement invariant.
Root cause: the ZAI gate returns [] to mean 'known-empty' (no
reasoning_effort at all), but the coercion path treated all [] as
'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision
into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by
both the filter and coercion, then special-casing the known-False result in
coerce_reasoning_effort_for_model to return '' (send no field). The #3505
preserve-verbatim behavior for genuinely-unknown models on non-zai providers
is unchanged.
Added 10 regression tests (all fail before the coercion fix, pass after):
- All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7
- All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate
- GLM-5.2 preserves all 6 levels verbatim
- Regression guard: unknown model on custom: provider STILL preserves verbatim
State layer: agent.reasoning_effort config + the value forwarded to Z.AI.
Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion
sends no reasoning_effort field for any stored level on those models.
* fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty
Address nesquena-hermes round-2 review on #6219: returning [] for the effort
ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer
(static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at
all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/
5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking
{type:enabled|disabled} toggle even though they do not accept the
reasoning_effort intensity ladder.
Fix decouples thinking-toggle capability from the effort ladder:
Backend (api/config.py):
- Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking'
(GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None
(non-zai / non-GLM). Single source of truth shared by all three consumers.
- _zai_glm_reasoning_efforts_supported now wraps classification for the coercion
contract (unchanged behavior).
- _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking',
False for 'forced', None otherwise.
- get_reasoning_status gains a supports_thinking_toggle field = bool(supported)
OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts)
so their chip-visibility behavior is unchanged.
Frontend (static/ui.js):
- New _currentReasoningToggleSupported state var (default undefined = treat as
true so legacy responses without the field do not newly hide the chip).
- _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported.
Empty efforts + toggle=True keeps the chip visible with just the None/On
control (the existing _applyReasoningOptions already shows 'none' when the
ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it.
- Profile-transition and fetch-failure resets now pass
supports_thinking_toggle:false alongside the empty efforts so the chip hides
during the unknown-state window, matching the prior reset contract.
Tests (30 new):
- _zai_glm_classification parametrized across all three tiers + aliases + defer
- get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6
toggle-only, GLM-4.7 neither, non-zai defaults to effort capability)
- Frontend _applyReasoningChip behavior via node driver: empty efforts +
toggle=True stays visible, toggle=False hides, effort ladder alone is
sufficient, absent field keeps prior behavior
State layer: agent.reasoning_effort config + supports_thinking_toggle field in
/api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY
when the model supports neither the effort ladder nor the thinking toggle
(GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the
working On/None control they had before the round-1 effort gate.
* fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none
Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the
thinking-toggle path, plus a click-handler sibling I found while auditing.
Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247,
static/ui.js:4902, static/index.html:757):
The round-2 fix kept the chip visible for thinking-tier models but the only
rendered dropdown option was 'None' (the HTML had no Default option, and
set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could
turn thinking OFF but never back ON — worse than the original bug.
Fix:
- set_reasoning_effort now accepts empty effort as 'clear the override' (removes
agent.reasoning_effort so the provider default takes effect). Invalid values
still raise ValueError.
- static/index.html gains a <div data-effort=''>Default</div> option.
- _applyReasoningOptions always shows both Default ('') and None alongside the
effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders
an operable Default+None two-state control.
- Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness
(if(effort)) — the old check silently ignored data-effort='' clicks, which
would have left the Default button dead even after the HTML/backend changes.
Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857):
When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved
'none' via the early return at line 3985, so streaming built disabled reasoning
for a model that forces thinking on regardless. Separately, when the raw
capability source listed 'none', resolve_model_reasoning_efforts reattached it
to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI
for a forced-thinking model.
Fix:
- coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced'
BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default
= thinking on) for forced models.
- resolve_model_reasoning_efforts returns [] early for forced-tier models,
skipping the 'none' reattachment entirely.
Tests (17 new):
- Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM
still accepts 'none'; resolve does not reattach 'none' for forced but DOES
for thinking-tier; end-to-end get_reasoning_status for forced+stored-none.
- Gap #1 backend: set('') clears the key (no raise), set('garbage') still
raises, all 7 valid levels still save.
- Gap #1 frontend: three new node-driver tests asserting the dropdown exposes
both Default and None for thinking-tier (two-state), Default+None+ladder for
effort-tier, and the off->on->off round trip keeps both visible throughout.
- Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to
reflect the new contract (empty accepted, garbage rejected).
Regression gate: gap#2 coerce tests fail without the forced-tier check (3
failures); gap#1 backend test fails with 'ValueError: effort is required'
without the empty-acceptance change. 219 passed, 1 pre-existing skip across
the full reasoning + chip + config-cache surface.
State layer: agent.reasoning_effort config + supports_thinking_toggle field +
composer dropdown options. Invariant: the thinking toggle is now genuinely
two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and
GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve,
status, or UI).
* CHANGELOG: GLM per-version reasoning controls (#6219)
---------
Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
|
Shipped in exp-v0.52.105. Thanks @rh-id — great persistence through 4 gate rounds! 🎉 The final gate verified the full off→on→off toggle round trip for GLM-4.5/4.6/5.0/5.1, GLM-4.7 forced-thinking even with a stored none, GLM-5.2+ full ladder intact, and all 3 surfaces agreeing across tiers/aliases/namespaces. |
… permissions (#3) * docs: fold in Rod's PR-review feedback into GUIDELINES + CONTRIBUTING (#6211) * docs: fold in Rod's PR-review feedback into GUIDELINES + CONTRIBUTING Based on rodboev's feedback distilled from 50+ recent PRs. Four accepted points plus one clause, folded into existing rules rather than adding new ones: - Rule 6: load the reporter's shipped reproduction, don't rebuild a fixture from your reading of it (the one genuine hole — a fix and test from the same wrong model agree with each other and certify a no-op). - Rule 2: confirm a value is authoritative (declared at the point of intent), not inferred from id prefix / content shape / emptiness / DOM state. - Rule 4: read prior PRs and review threads to find a subsystem's real variants instead of inventing axes from the single case handed to you. - Rule 1: the chokepoint is the smallest boundary that contains the fault, not the widest you can reach (don't disable a whole pipeline to suppress one output). - "Show your work": name who owns the truth for any claim the repo doesn't own. CONTRIBUTING.md carries the two contributor-facing points (repro-loading, proof-ownership) in the PR-description section, deferring detail to GUIDELINES.md. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * docs: tighten Rule 6 escape hatch per Rod — gate on shape under-specified, not file absent Rod's review: the #5749 no-op came from a fully prose-specified repro (fenced JSON + field-level conditions + steps), not a missing file. The old hatch ('why the issue gave you nothing to load') reads as 'no downloadable attachment', letting someone walk past a binding JSON block. Gate the hatch on the SHAPE being under-specified instead: a fenced JSON structure / field conditions / step list pin the shape as bindingly as a file; if pinned, satisfy every condition and don't add a property the shape never had to make a guard fire; only say 'constructed, assumed X' when the shape is truly unpinned. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * docs: distill Rule 6 repro-shape guidance into a principle (Rod style note) Rod: 'distill into durable principles, don't enumerate lists; strip negative conditions that read like narration.' Reworked the addition to lead with the principle ('a reproduction is whatever pins the bug's shape'), collapse the capture/JSON/conditions/steps enumeration into flowing prose, and convert the 'don't add a property...' negative into a positive imperative ('bind your fixture to that shape: satisfy every condition... instead of granting...'). Same for the CONTRIBUTING bullet. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com> * Release: msg_limit ceiling metadata decoupling (#6214, @webtecnica) (#6216) * feat: expose msg_limit ceiling via /api/session metadata, frontend reads dynamically (#6177) Backend exposes _MAX_MSG_LIMIT as _msg_limit_max in every /api/session response. Frontend reads it dynamically, falling back to _MSG_LIMIT_MAX for older servers. This removes the hand-mirrored coupling between the two layers. Removed test_msg_limit_ceiling_drift.py since the mirror pattern that required the drift guard is replaced by dynamic metadata. This is the standalone metadata-decoupling piece from #6206, without the clamp/paging that already shipped in exp-v0.52.98 via #6152/#6154. * fix(session): declare _msgLimitMax at module scope + CHANGELOG + gate fixes (#6214 follow-up) The submitted PR used _msgLimitMax at two read sites (boundedReloadLimit in _ensureMessagesLoaded, useBeforePaging in _loadOlderMessages) but never declared it and read it before assignment -> undefined on cold load -> full-transcript fetch every load (regression) + implicit global. Declared `let _msgLimitMax = _MSG_LIMIT_MAX;` at module scope so the reload-width paths always read a defined value (the static fallback) until the server's _msg_limit_max lands. Also: defined the ceiling globals in the two inline node-harness tests that copy _ensureMessagesLoaded's body (test_cross_session_message_load_isolation, test_session_unread_dot_on_visit), updated the source-string assertion in test_webui_external_refresh_frontend, and added 3 decoupling tests (backend field present, module-scope declaration + fallback, both paths read the live ceiling) replacing the deleted drift-guard. Gate: Codex adversarial SAFE TO SHIP (executable probes: cold-load fallback, mixed-version omission, live update, cross-session isolation, over-ceiling bare refresh, msg_before row preservation). Full sharded suite green. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Transparent Stream multi-segment prefix dedupe (#6189, @ai-ag2026) (#6217) * fix(transparent-stream): drop stale final-answer prefix row in multi-segment settle (#5749 follow-up) A turn with interim assistant messages (prose interleaved with tool calls) could show the beginning of the final answer TWICE after watching it stream: once as a settled anchor-scene prose row (the live-token accumulator's last throttled snapshot) and once as the real assistant segment. The duplicate persisted until reload. Root cause: #5758 suppresses the accumulator row only when it sits "after the last tool row", but _completeSettledAnchorSceneForTurn appends the settled per-message tool rows AFTER the projected live rows — those re-list tools that ran EARLIER in the turn, pushing the boundary past the final segment's accumulator so the guard never fired. The stale prefix snapshot then survived into the persisted scene and rendered above the settled answer. Fix: judge final-segment eligibility against the LIVE projection's own chronology — a live-prose row belongs to the final segment iff no PROJECTED tool row follows it. Pre-tool narration that happens to prefix the final answer stays protected (existing #5758 regression tests still pass), and a new regression test pins the multi-segment shape. Verified end-to-end by replaying the captured run journal of an affected session through the real SSE live handlers in headless Chromium: duplicate before (answer prefix visible twice after settle), gone after; fresh-reload rendering unchanged. Rollback: revert this commit; behavior returns to pre-fix (duplicate prefix row after live-settle of multi-segment turns). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * CHANGELOG: transparent-stream multi-segment prefix dedupe (#6189, @ai-ag2026) --------- Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Live Stream: hydrate ID-linked historical tool turns * Release: stop false Compressing-context card (#6184, @carlotestor) (#6223) * fix: stop false "Compressing context" on non-compress turns Narrow the agent status → SSE compressing bridge to real Hermes compaction start notices, and stop snapshot hydration inventing a running compress divider from terminal/lifecycle rows without cues. Brand-new low-token chats (and skip/cooldown notices) no longer paint the live auto-compression worklog row. * CHANGELOG: false compressing-context card fix (#6184) --------- Co-authored-by: carlotestor <89560945+carlotestor@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: extension session-open handler + renderTranscript API (#5508, @ChonSong) (#6226) * feat(core): add registerHermesSessionOpenHandler + renderTranscript extension hooks - registerHermesSessionOpenHandler(fn): register a handler that fires on session open. Return {cancel:true} to prevent navigation. - renderTranscript(container, messages, opts): render messages into any DOM container using core's renderMd pipeline. Skip tool messages. - Wire _hermesNotifySessionOpen into loadSession: pre-load guard at top, post-load notification at end for extensions to hook into. - Follows existing registerHermesTtsEngine extension registration pattern. This gives extensions like chat-tiling a sanctioned API instead of DOM hacking to intercept and render session transcripts. * fix: address 3 gate-fail blockers from PR #5508 review Fixes the three core issues identified by nesquena-hermes code review: 1. XSS sink in renderTranscript (boot.js) - Fallback to textContent when window.renderMd is unavailable - innerHTML exclusively for successful renderMd output 2. Stale _loadingSessionId reset in cancel branch (sessions.js) - Remove premature nulling of _loadingSessionId - Cancel path simply returns without touching loading-guard state 3. Pre-open veto bypasses profile/import side-effects (sessions.js) - Move cancellable preload hook to start of _openSidebarSession, before external session import and profile switching - Pass internal _preloadNotified flag to skip duplicate preload in loadSession while retaining post-load notification Closes #5508 gate-fail items. * fix: use module-level flag instead of call argument to avoid test regression The _preloadNotified approach broke test_static_sessions_js_switches_profile before_opening_all_profiles_row because it changed the loadSession call signature from loadSession(sid, loadOpts) to loadSession(sid, Object.assign(...)). Switch to a module-scoped boolean _hermesSessionOpenAlreadyFired set by _openSidebarSession before calling loadSession, checked by loadSession's pre-hook guard. The call signature stays unchanged. Test 2 (test_load_session_rearms_stream_on_every_early_return) also passes. * fix: compact pre-hook comment to keep loadSession within test window limits * fix: replace global _hermesSessionOpenAlreadyFired flag with per-call opts._preloadNotified The module-level boolean introduced in 381fa0ef had two problems: 1. ReferenceError on direct loadSession() calls — the flag was undeclared when called outside _openSidebarSession, breaking saved-session restore. 2. Never reset after first sidebar open — all subsequent direct calls silently skipped the cancellable preload handler. Per the maintainer's review (PR #5508), replace the global with a per-call option _preloadNotified passed by _openSidebarSession via Object.assign. This keeps the call signature stable for existing tests and eliminates the stale global state leak. Test adjustments: - test_issue1611: widened loadSession call literal assertion - test_session_channel_option_x: body slice 14000→15000 to accommodate the slightly longer function body * chore: remove unrelated files from commit * fix: address 3 review issues — preload-only cancel, drop inner wrapper, pass _preloadNotified on retry - Only honor {cancel:true} when opts.preload===true (boot.js) - Drop .msg-body-inner wrapper, render directly into .msg-body (boot.js) - Carry _preloadNotified:true through cross-profile 409 retry (sessions.js) * recommit * Update static/boot.js Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(extensions): resolve canonical sid before preload hook + pass _preloadNotified on continuation retry PR #5508 review follow-up (review 4690636760): 1. Move _resolveSessionIdFromSidebarLineage() before the preload hook so extensions always see the canonical sid, not the raw sidebar click id. 2. Pass _preloadNotified:true on the continuation-session retry path to prevent duplicate preload events to extensions. 3. Add functional test_extension_session_hooks.py — actually drives the new hook registration, preload-veto, transcript rendering, and _preloadNotified bridge in Node (13 tests, all green). * Remove unused pytest import Removed unused import of pytest from test file. * fix: address 2 gate-blocking veto-ordering defects (PR #5508) Blocker 1 (CORE): cross-profile retry now passes _preloadNotified:true so the pre-hook doesn't re-fire after destructive side-effects already ran (stream teardown, message clear, profile switch). A {cancel:true} on that second fire was stranding the UI profile-switched with a cleared transcript. Blocker 2 (SILENT): closeMobileSidebar() was called synchronously BEFORE _openSidebarSession()'s veto guard in three places (tap-to-open, child-session, lineage-segment). A {cancel:true} still closed the sidebar out from under it. Removed the three premature calls; moved a single closeMobileSidebar() inside _openSidebarSession AFTER the veto guard so it only runs when the open actually proceeds. Added 3 regression tests asserting {cancel:true} leaves NO side-effect. * CHANGELOG: extension session-open handler + renderTranscript API (#5508) --------- Co-authored-by: Sean <seanos1a@gmail.com> Co-authored-by: ChonSong <85378550+ChonSong@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * test: cover ordered multi-tool anchor hydration * Release: durable run-journal recovery + full tool args (#6197, @franksong2702) (#6236) * fix(streaming): prefer durable run journal recovery * fix(streaming): reject stale recovery stream scenes * test(streaming): pin todo recovery metadata guard * fix(streaming): preserve recovery snapshot tool args * test(streaming): align journal snapshot args contract * fix(streaming): bound recovery snapshot tool args * CHANGELOG: durable run-journal recovery + full tool args (#6197) --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: folder-download subpath baseURI fix (#6227, @steezypunk) (#6237) * fix(ui): resolve folder download URL against document.baseURI for subpath support When Hermes WebUI is served behind a reverse proxy with a path prefix (e.g. /hermes/), the right-click → "Download Folder" context menu option navigates to a root-absolute URL (/api/folder/download?...), which resolves to the server origin instead of the proxy mount point, causing a 404. This matches the pattern already used by the workspace.js route helper refactored in v0.52.41 (commit 1a64d7d3). * CHANGELOG: folder-download subpath baseURI fix (#6227) --------- Co-authored-by: Steezy <21984836+steezypunk@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Atomic config.yaml writes to survive mid-write crashes config.yaml and profile config.yaml were persisted with a plain Path.write_text(), which truncates the target before writing. A crash (or exception) after the truncate but before the full payload was flushed left the live config truncated/corrupt, so the next agent/WebUI start failed to parse it (availability regression). Extract a shared api.paths._atomic_write_text() helper (tempfile in the same dir -> write -> flush + os.fsync -> os.replace; unlink tmp on error), mirroring the existing .env / cost-snapshot atomic pattern in api.providers, and apply it to _save_yaml_config_file (api.config) and the two profile model-config writers (api.profiles). On any mid-write failure os.replace never runs, so the original file stays byte-for-byte intact. Preserve the target's permissions: tempfile.mkstemp() hard-codes 0600 and os.replace carries the temp file's mode onto the target, so without an explicit chmod every save would silently tighten a group/other- readable config.yaml (the homelab install ships 0644, profiles 0664) down to owner-only. Copy the existing file's mode before the replace, falling back to the umask-adjusted 0666 for a new file. config.yaml holds no secrets, so that tightening would be a regression, not hardening (unlike .env, which stays 0600 in api.providers). settings.json is intentionally left untouched here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Cover onboarding with atomic config writes * Handle unsupported ownership transfer in atomic writes * Preserve hard-linked configs during atomic writes * fix: preserve config extended attributes * fix: avoid racy umask probe for config writes * fix(ctl): detect foreign/supervised WebUI instances instead of double-starting ctl.sh start only guarded against launchd (macOS). On Linux, with a systemd-supervised WebUI serving the port and a stale PID file, stop reported 'stopped', start spawned a bootstrap that died ~2s later on server.py's 'already responding' check — after the 0.15s aliveness gate had already printed 'Started' and recorded the doomed PID. Killing the foreign server by hand then put its supervisor's auto-restart into a race with ctl.sh's start, ending in a permanent RestartSec crash loop. - start: refuse when anything answers HTTP(S) on the target port (any response bytes, matching server.py's abort semantics — a 404 squatter still dooms our server), and when the hermes-webui systemd unit is active on our port or mid-auto-restart (activating). Port scoping mirrors the launchd #3291 over-block fix; overrides: HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT / _ALLOW_PORT_CONFLICT, unit name via HERMES_WEBUI_SYSTEMD_UNIT. - start: watch the child through a startup grace window (HERMES_WEBUI_START_GRACE, default 3s) — report failure and clean the PID file when it dies during startup; break early once /health answers. - status/stop: when ctl.sh owns no PID but the port answers, say 'running (not managed by ctl.sh)' with listener diagnostics instead of 'stopped', and never touch the foreign process. - _pid_listens_on_port: ss fallback for Linux hosts without lsof. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ctl): harden the foreign-instance guards per review Four fixes from the Greptile review on #5944: - Bracket IPv6 literals in the probe target ('::1' -> '[::1]') so the URL-based responder checks don't silently miss a running instance. - Force direct connections in the local ownership probes: --noproxy '*' (curl) / --no-proxy (wget) in _port_answers_http, and neutralized proxy env around the startup-grace health probe — a configured http(s)_proxy would report the proxy instead of the port. - Clamp HERMES_WEBUI_START_GRACE=0 to the default: a zero window would skip startup monitoring entirely and restore the stale-PID behavior the window exists to prevent. - stop: warn about an unmanaged instance BEFORE deleting the state file — it carries the saved host/port binding the probe needs when the instance was started off-default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ctl): keep listener diagnostics best-effort * fix: bypass proxies in ctl startup health probe * test: allow startup monitor cleanup in dotenv test * test: give ctl start fixtures startup grace * Release: GLM per-version reasoning controls (#6219, @rh-id) (#6243) * fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but hemes-webui advertised the full 6-level ladder for all 7 GLM models because _candidate_supports_reasoning has an unconditional glm token match and _filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking that cannot be disabled per Z.AI docs) showed a 'none' option with no effect. Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM models and for the forced-thinking GLM-4.7 family; preserve the full ladder for GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all resolve to zai); aggregator providers are untouched because they route through their own routers, not Z.AI's native endpoint. The glm family-detection heuristic in _candidate_supports_reasoning is unchanged — GLM models DO support the thinking on/off toggle at the family level; this fix is specifically about the reasoning_effort intensity ladder. State layer: agent.reasoning_effort config + UI dropdown options derived from resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never offered for forced-thinking models). Out of scope: the thinking:{type:...} request-field translation lives in the external agent/gateway layer. * fix(reasoning): close ZAI coercion gap + harden test assertions Address Greptile review on #6219 (round 1): 1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or' fallback made the assertion always-true, so a regression stripping 'none' for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source (mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the test now genuinely exercises the preservation branch. 2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing 'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no rule' (preserving the configured effort verbatim per #3505), so a stored 'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded to Z.AI unchanged and silently ignored — contradicting the PR's stated UI/coercion agreement invariant. Root cause: the ZAI gate returns [] to mean 'known-empty' (no reasoning_effort at all), but the coercion path treated all [] as 'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by both the filter and coercion, then special-casing the known-False result in coerce_reasoning_effort_for_model to return '' (send no field). The #3505 preserve-verbatim behavior for genuinely-unknown models on non-zai providers is unchanged. Added 10 regression tests (all fail before the coercion fix, pass after): - All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7 - All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate - GLM-5.2 preserves all 6 levels verbatim - Regression guard: unknown model on custom: provider STILL preserves verbatim State layer: agent.reasoning_effort config + the value forwarded to Z.AI. Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion sends no reasoning_effort field for any stored level on those models. * fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty Address nesquena-hermes round-2 review on #6219: returning [] for the effort ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer (static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/ 5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking {type:enabled|disabled} toggle even though they do not accept the reasoning_effort intensity ladder. Fix decouples thinking-toggle capability from the effort ladder: Backend (api/config.py): - Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking' (GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None (non-zai / non-GLM). Single source of truth shared by all three consumers. - _zai_glm_reasoning_efforts_supported now wraps classification for the coercion contract (unchanged behavior). - _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking', False for 'forced', None otherwise. - get_reasoning_status gains a supports_thinking_toggle field = bool(supported) OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts) so their chip-visibility behavior is unchanged. Frontend (static/ui.js): - New _currentReasoningToggleSupported state var (default undefined = treat as true so legacy responses without the field do not newly hide the chip). - _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported. Empty efforts + toggle=True keeps the chip visible with just the None/On control (the existing _applyReasoningOptions already shows 'none' when the ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it. - Profile-transition and fetch-failure resets now pass supports_thinking_toggle:false alongside the empty efforts so the chip hides during the unknown-state window, matching the prior reset contract. Tests (30 new): - _zai_glm_classification parametrized across all three tiers + aliases + defer - get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6 toggle-only, GLM-4.7 neither, non-zai defaults to effort capability) - Frontend _applyReasoningChip behavior via node driver: empty efforts + toggle=True stays visible, toggle=False hides, effort ladder alone is sufficient, absent field keeps prior behavior State layer: agent.reasoning_effort config + supports_thinking_toggle field in /api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY when the model supports neither the effort ladder nor the thinking toggle (GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the working On/None control they had before the round-1 effort gate. * fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the thinking-toggle path, plus a click-handler sibling I found while auditing. Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247, static/ui.js:4902, static/index.html:757): The round-2 fix kept the chip visible for thinking-tier models but the only rendered dropdown option was 'None' (the HTML had no Default option, and set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could turn thinking OFF but never back ON — worse than the original bug. Fix: - set_reasoning_effort now accepts empty effort as 'clear the override' (removes agent.reasoning_effort so the provider default takes effect). Invalid values still raise ValueError. - static/index.html gains a <div data-effort=''>Default</div> option. - _applyReasoningOptions always shows both Default ('') and None alongside the effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders an operable Default+None two-state control. - Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness (if(effort)) — the old check silently ignored data-effort='' clicks, which would have left the Default button dead even after the HTML/backend changes. Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857): When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved 'none' via the early return at line 3985, so streaming built disabled reasoning for a model that forces thinking on regardless. Separately, when the raw capability source listed 'none', resolve_model_reasoning_efforts reattached it to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI for a forced-thinking model. Fix: - coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced' BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default = thinking on) for forced models. - resolve_model_reasoning_efforts returns [] early for forced-tier models, skipping the 'none' reattachment entirely. Tests (17 new): - Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM still accepts 'none'; resolve does not reattach 'none' for forced but DOES for thinking-tier; end-to-end get_reasoning_status for forced+stored-none. - Gap #1 backend: set('') clears the key (no raise), set('garbage') still raises, all 7 valid levels still save. - Gap #1 frontend: three new node-driver tests asserting the dropdown exposes both Default and None for thinking-tier (two-state), Default+None+ladder for effort-tier, and the off->on->off round trip keeps both visible throughout. - Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to reflect the new contract (empty accepted, garbage rejected). Regression gate: gap#2 coerce tests fail without the forced-tier check (3 failures); gap#1 backend test fails with 'ValueError: effort is required' without the empty-acceptance change. 219 passed, 1 pre-existing skip across the full reasoning + chip + config-cache surface. State layer: agent.reasoning_effort config + supports_thinking_toggle field + composer dropdown options. Invariant: the thinking toggle is now genuinely two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve, status, or UI). * CHANGELOG: GLM per-version reasoning controls (#6219) --------- Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: intercept /sessions and /resume slash commands (#6245, @webtecnica) (#6253) * fix: intercept /sessions and /resume slash commands in WebUI (#6224) Add a native-intercept branch in the block of send() alongside the /pet special-case. When the user types /sessions or /resume, expand the sidebar and refresh the session list instead of sending the raw slash text to the agent. Root cause: the agent command registry exposes sessions/resume as non-CLI-only commands, so the autocomplete popup shows them, but the WebUI send-time dispatch had no branch to catch them, causing the literal text to be sent as a prompt. * fix(commands): use mobile-aware session-browser opener for /sessions /resume (gate follow-up) The intercept called expandSidebar() directly, which is a no-op on phone-width layouts, so /sessions and /resume silently did nothing on mobile (composer cleared, nothing shown). Use the mobile-aware _openProfileSwitchSessionBrowser() first, falling back to expandSidebar(). Reproduced + specified by the pre-release Codex gate. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * CHANGELOG: intercept /sessions /resume slash commands (#6245) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: OIDC allowlist whitespace fix (#6244, @webtecnica) (#6259) * fix: split OIDC allowlist on commas only, preserve scope whitespace-split (#6244) _normalize_text_list is shared with _normalize_scopes — OAuth scopes are space-delimited per RFC 6749 §3.3. Created _normalize_allow_values that splits on commas/newlines only, keeping multi-word group names like 'Hermes Users' intact. * fix(oidc): filter blank allow_values list elements + add parser-split test (gate follow-up) The new comma/newline-only _normalize_allow_values() list-path retained empty strings that the shared _normalize_text_list() had filtered, so a YAML allow_values: [""] would brick an OIDC-only deployment (every callback 403s). Filter stripped-empty collection elements. Adds a regression test asserting allowlist multi-word preservation, comma/newline splitting, blank filtering, and that scopes stay space-delimited (RFC 6749 §3.3). Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * CHANGELOG: OIDC allowlist whitespace fix (#6244) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: remove dead rowIndex param from settled-scene pushRow (#6258, @webtecnica) (#6262) * fix(transparent-stream): remove dead rowIndex param from pushRow (#6189 follow-up) In #6189 / #6217 the final-segment eligibility was migrated from an index comparison (rowIndex > lastNonTerminalWorkRowIndex) to a WeakSet lookup (finalSegmentLiveProseRows.has(row)). The rowIndex parameter on pushRow became dead code — it's declared but never read, and the call-site still passes idx from forEach. Remove the unused parameter and simplify the call-site. This addresses the greptile review feedback on the original PR. Closes #6189 * CHANGELOG: dead rowIndex param removal (#6258) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: byte-size threshold for reconnect tail optimization (#6260, @webtecnica) (#6263) * perf: optimize large session reconnect by adding file-size threshold to tail optimization (#6241) When a sidecar JSON file exceeds 500 KB, the display-path tail optimization now fires even if the message count is within the raw_budget. This prevents sessions with few messages but large tool outputs (multi-MB JSON) from forcing a full-scan merge of all messages on reconnect. Changes: - Added _sidecar_file_exceeds_threshold() helper - Added _SIDECAR_BYTE_TAIL_THRESHOLD = 500_000 constant - Fall-through to truncation in _state_db_since_timestamp_for_limited_display when the sidecar file exceeds the threshold, regardless of message count * CHANGELOG: byte-size reconnect tail optimization (#6260) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * fix(config): restore read-only-target protection for atomic writes Review finding 2 (10.07 gate): a deliberately locked config (0444) in a writable directory was silently overwritten — atomic replace creates a fresh temp inode and renames over the read-only file, defeating the old in-place Path.write_text PermissionError contract. Probe the existing target with a non-truncating O_WRONLY open before any replacement work and let the PermissionError propagate. The probe fstat()s the fd it actually opened and hands that stat to the rest of the write, so a concurrent writer replacing the inode between stat and probe refreshes the metadata instead of failing (keeps concurrent-writer semantics). Regression: writable parent + 0444 target now raises and keeps the original bytes and mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ctl): close all three 12.07 re-gate findings on the systemd guard 1. inherit_errexit: the listener-diagnostic assignments in _port_listener_diag abort status/stop when errexit is inherited into command substitutions (shopt inherit_errexit or a BASHOPTS env from the invoking shell). Guard both assignments with || true; regression test runs status/stop under BASHOPTS=inherit_errexit. 2. PID/port AND: _pid_listens_on_port called lsof -p PID -iTCP:PORT without -a, which OR-combines the selectors — any socket of the PID or any listener on the port matched, so an active unit could be blamed for a port its MainPID does not listen on. Add -a; the new fake lsof mimics real OR/AND semantics so a missing -a fails the test. 3. Binding-aware collision: an active/activating unit whose ownership could not be attributed via MainPID was assumed to own port 8787 unconditionally. Resolve the unit's configured binding first (HERMES_WEBUI_PORT from Environment=, then --port from ExecStart=) and refuse only on actual overlap; the default-port guard remains solely for undeterminable bindings (#3291 semantics). Tests cover the reverse alternate-port case (unit on 9999, start on 8787 proceeds) and the overlap case (unit on the requested port refuses). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: deduplicate configured model badges (#6221) Deduplicate configured model badges so one configured model shows a single picker entry, with provider-collision + colon-bearing-id routing correctness. Thanks @happy5318. Co-authored-by: happy5318 <happy5318@users.noreply.github.com> * Release: deduplicate configured model badges (#6221, @happy5318) (#6268) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * fix(renderer): render data:image URIs as images instead of raw base64 text (#6209) Render data:image URIs as inline images (raster + base64 SVG) instead of raw base64 text, route file:// images through the media pipeline, with a strict allowlist + 2MB cap and img-only data: sanitizer. Thanks @ai-ag2026. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com> * Release: render data:image URIs as images (#6209, @ai-ag2026) (#6270) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * Fail closed on historical anchor hydration throws * docs(changelog): stamp v0.52.76 stable section (promotion) (#6269) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * ci: docs-only fast-path + minimal docs CI (#6279) * ci: docs-only fast path + minimal docs CI Skip the full pytest matrix + browser smoke on docs/CHANGELOG/README-only PRs (required checks still report green fast via a fail-safe 'changes' gate job), and add a lightweight Docs CI: critical_markdown_check.py (rendering-breaks only, not style) + lychee broken-link check. Detection fails safe (any uncertainty or any non-doc path -> full suite runs). * ci: tighten docs-only detection — extension/type wins over name A code file whose NAME contains README/CHANGELOG (scripts/CHANGELOG_stamp.py, static/README_renderer.js) was wrongly classified docs-only, which would SKIP the test suite on a real code change. Now a path is docs only by doc extension, exact doc basename, or non-code file under docs/. Verified against 16 cases incl. every code-with-docs-name trap. * ci: fix all Fable+Codex gate findings on docs-only fast path - BLOCKER: drop *.txt from is_docs (requirements.txt is a dep manifest — a bump would have skipped the whole test matrix). docs = *.md/.markdown/.rst + bare doc basenames only; strict allowlist, no docs/** denylist. - Rename hole: use 'git diff --name-only --no-renames' so a rename src/app.py -> docs.md reveals the code-side deletion instead of collapsing to the doc dest. - SECURITY: docs-ci.yml no longer interpolates untrusted fork-PR filenames into run: via ${{ }} (which executes $() in a crafted name). File list flows through a file + mapfile-as-args; lychee gets a fixed glob, not the attacker list. Added permissions: contents: read. - Wedge belt-and-suspenders: 'if: ${{ always() }}' on the required test + browser-smoke jobs so a failed 'changes' job can't skip them; step guards treat missing/empty docs_only as run-full. - critical_markdown_check.py: corrected the core rule — a newline in the whitespace AROUND a link destination is valid CommonMark (was a false positive); only a newline INSIDE the destination token, or an unclosed inline link, breaks rendering. Verified full agreement with the markdown-it-py reference parser + 0 false positives on all 43 repo docs. Also blank 4-space indented code + multi backtick spans. Reworded 'block the merge' -> 'break rendering' (non-required). * ci: address Codex re-gate — lint always() + markdown title/unclosed cases - Add if: always() to the lint job too (not currently required, but future-proof against the wedge class if it's ever promoted). - critical_markdown_check.py: handle two more CommonMark cases Codex found — a newline inside a "title" string is legal (skip), and a newline-terminated unclosed dest ([x](url\n at EOF/EOL with no close) is broken (flag). After the destination token ends, valid continuations are ')' or a title opener (" ' (); bare text after the newline is the real break. Verified full agreement with markdown-it-py across 13 cases + 0 FP on all 43 repo docs. * ci: model the CommonMark inline-dest grammar (root-cause fix for markdown checker) Round-3 gate found the title heuristic caused sibling regressions: a parenthesized multi-line title (url (a\nb)) was falsely flagged, and a quote glued into the URL (exa"part) was wrongly treated as a title-start and skipped. Rather than patch more heuristics, replace the ad-hoc newline logic with _scan_inline_dest(), which walks the actual grammar: skip leading ws -> bare dest (balanced parens, ends at ws or the depth-0 ')') or <angle> dest -> after ws the next char must be ')' or a real title opener (" ' () -> else the destination is split across the line (broken). Verified FULL agreement with markdown-it-py across 20 adversarial cases (incl. both round-3 regressions, balanced parens, angle dests, multiline titles) + 0 FP on all 43 docs. * test: pytest suite for critical_markdown_check (42 cases) Durable, repeatable verification for the docs-CI markdown checker: 19 verdict cases + 19 cross-checked against the markdown-it-py CommonMark reference (skips cleanly if the lib is absent) + 3 code-span-safety cases + empty/no-link inputs. Covers both round-3 regressions (parenthesized multi-line title valid; quote-glued-in-URL broken), balanced parens, angle destinations, and multiline titles. 42 passed. * ci: fix 2 grammar edges from Codex round-4 (escaped-> in angle dest, unbalanced bare-dest parens) - Angle dest <...> now honors backslash escapes: [x](<foo\>bar>) renders (the \> is escaped), was a false positive. - Bare dest must have BALANCED parens: [x](foo(\n)) does not render (a '(' stays open when whitespace ends the token) — now returns split, was a false negative. Both verified against markdown-it-py + added as pytest cases. 46 passed, 0 FP on 43 docs. * ci: escaped-newline in angle dest is still a raw newline (Codex round-5) <...> escape handling skipped the char after backslash including a newline, so [x](<foo\<nl>>) returned ok but doesn't render (blockquote on line 2). An escaped newline inside an angle destination is still a raw newline -> split. Preserves [x](<foo\>bar>). Added regression case. 48 pytest cases pass, 0 FP on 43 docs. * ci: mirror escaped-newline guard to bare dest (Codex round-6, GFM-correct) The angle branch already treated backslash-newline as split; the bare branch skipped it, so [x](foo\<nl>bar) returned ok. GFM/CommonMark forbid line endings in bare destinations (GitHub's cmark-gfm won't render it), though markdown-it-py permissively does. Since these docs are GitHub-rendered we follow GFM: flag it. Added as a SPEC_DIVERGENT test case (verdict-asserted, excluded from the permissive parser cross-check). 49 pytest pass, escaped paren/space still valid, 0 FP on 43 docs. * ci: delimiter-aware title scan (Codex round-7 unclosed-link false negatives) Naive text.find(')') matched the TITLE's own ')' not the link's outer ')', so [x](foo (title), [x](<foo> (title), [x](foo "title" all returned ok despite being unclosed links. Now parse the title to its actual closing delimiter ("..", '..', or a (..) that forbids nested unescaped '(' per CommonMark), then require the link's own ')' after optional whitespace. Also catches the nested-paren-title break [x](foo (a (b) c)). Verified vs markdown-it-py; escaped parens + single-quoted titles containing parens still valid. 61 pytest pass, 0 FP on 43 docs. * ci: remove unused variable (ruff F841 in critical_markdown_check) The CI lint gate (ruff forward E9+F+B on new lines) caught a dead 'stripped = line.lstrip()' leftover from an earlier refactor in _blank_code — the fence detection matches on the raw line. Removed. No behavior change (61 pytest pass). --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * fix(models): prevent bare-id picker revert when provider hint is empty (#6195) (#6199) Prevent bare-id model picker revert when the provider hint is empty: an ambiguous bare id that collides across provider groups no longer snaps to the default group on re-render. Adds a revert-sensitive regression test and fixes three cross-file test-isolation leaks found while gating. Thanks @webtecnica. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * Release: prevent bare-id picker revert on empty provider hint (#6199, @webtecnica) (#6280) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * Release: Artifacts filename-first + session-own-streaming + reduced-motion msg-row (#6161, #6165, #6166, @webtecnica) (#6282) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * fix(wakeup): route async-delegation completions by origin + durable-claim delivery (#6283) Route async-delegation completions by the immutable origin_ui_session_id (exact origin tab) and deliver them through a durable claim/complete/release lifecycle so they arrive exactly once, restart-safe, on both the background wakeup and next-turn drain paths. Combines #6185 (@carlotestor) + #6159 (@sysophelper-droid); supersedes #6002/#6225. Co-authored-by: carlotestor <carlotestor@users.noreply.github.com> Co-authored-by: sysophelper-droid <sysophelper-droid@users.noreply.github.com> * fix(#6240): fall back when test skills symlink is unavailable (#6276) Fall back to a copytree (with read-only handling) when the test-server fixture can't create the skills symlink on native Windows without SeCreateSymbolicLinkPrivilege (WinError 1314). Test-infra only. Thanks @rodboev. Closes #6240. * fix(wakeup): recover terminal process completions after restart (#6287) Recover checkpointed core background processes and rebuild PROCESS_SESSION_INDEX on WebUI startup, so an ordinary terminal(background=True, notify_on_complete=True) proc_* completion that outlives a WebUI restart can still wake its original session. Complements #6283 (which covered async_delegation completions). Thanks @allenliang2022. * Release: bg-process restart recovery (#6287) + Windows test-fixture symlink fallback (#6276) (#6294) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * fix(#6099): make transparent stream activity timestamps optional (#6130) Add an opt-in setting to hide Transparent Stream's per-event timestamp chips while keeping the response footer time visible, for users who found the per-event chips noisy. Thanks @rodboev. Closes #6099. * Release: optional Transparent Stream event timestamp chips (#6130, @rodboev) (#6300) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * Live Stream: add public conversation lifecycle browser gate (#6251) * test: add conversation lifecycle browser gate * test: wait for durable lifecycle settlement * test: harden lifecycle gate cleanup and startup * test: normalize gateway fixture request paths * test: harden conversation lifecycle gate * test: fix lifecycle request failure capture * test: align lifecycle CI dependencies * test: harden lifecycle gate waits * docs: align lifecycle gate setup command * test: harden lifecycle gate persistence wait * ci: scope conversation-lifecycle gate to relevant code paths Only run the playwright browser gate when the chat render/streaming surface it exercises actually changes (static/**, api/**.py, server.py, the test, deps, the workflow). Docs-only and unrelated PRs skip it entirely, keeping CI lean per the docs-only fast-path philosophy. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * Release: gateway approval_id fallback (#6168) + update-check guard (#6180) + state-dir test isolation (#6305) (#6332) * fix: catch unhandled exception in POST /api/updates/check (defensive hardening) (#6180) * fix: generate non-empty approval_id when gateway approval.request omits it (#6008) (#6168) * chore: mark update-check try/except as defensive-only guard, drop #6086 linkage Per maintainer review, the try/except wrapper is defense-in-depth only — it does NOT fix #6086 (root cause is signal/process-group reaping). Updated log message and added inline comment to make this explicit. Leave #6086 open. * test: isolate state-dir probes from user state * docs(changelog): stamp #6168 approval_id, #6180 update-check guard, #6305 test isolation --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: broadcast terminal output to every viewer (#5836, @ai-ag2026) (#6340) * feat(terminal): broadcast output to every viewer instead of one shared queue TerminalSession.output was a single queue.Queue read destructively by the SSE handler. Two tabs/windows viewing the SAME session each open their own EventSource, so two _handle_terminal_output handlers competed on that one queue: every PTY chunk was delivered to exactly one of them. Each tab saw a disjoint half of the byte stream, and only one ever received terminal_closed. Output now fans out, mirroring StreamChannel/SessionChannel: each SSE consumer subscribe()s its own queue (seeded with a bounded backlog so a first/late attach still replays the recent scrollback, preserving the old buffer-until-first- consumer behaviour), and put_output broadcasts to all subscribers. A slow viewer's queue drops its own oldest chunk (drop-oldest, isolated per subscriber) so one lagging tab can't starve another. The handler unsubscribes in a finally so the subscriber list can't grow. Stacked on the terminal fd-leak fix (same file). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: cover terminal broadcast lifecycle * fix: serialize terminal subscriber fanout * test: cover terminal unsubscribe publication race * restore timing-flaky pytest.skip on test_terminal_survives_short_lived_request_thread (keep API-updated body); changelog #5836 --------- Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: terminal-error settlement timing/seal (#6323) + Docker experimental builds (#6329) (#6341) * fix: preserve timing and seal tool rows on terminal error (#6309) * fix: publish Docker experimental builds to ghcr (#6298) - Add exp-v* trigger to release workflow so experimental tags build and push Docker images - Add :experimental floating tag for experimental channel, keeping :latest scoped to stable v* tags only - Mark GitHub Releases from exp-v* tags as pre-releases - Document available Docker tags (:latest, :experimental, version pins) in docs/docker.md Closes #6298 * docs(changelog): stamp #6323 terminal-error timing/seal + #6329 Docker exp builds --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: content search no longer evicts the working-set cache (#6084, @ai-ag2026) (#6343) * fix(webui): keep the content search from evicting the user's working set /api/sessions/search?content=1 walks EVERY session and pulls each one through get_session(), which inserts it into the SESSIONS LRU and marks it recently-used. On any install with more sessions than sessions_cache_max (default 300), a single search therefore flushes the whole cache and refills it with sessions the user is not looking at — the classic buffer-pool scan-pollution problem. The sessions actually open in the UI are exactly the ones evicted, and the search is keystroke-debounced, so it repeats while typing. A scan reads each session exactly once, so nothing it touches has earned "recently used". get_session_for_scan() reuses a resident session without promoting it, and reads a cold one straight from disk without caching it. It returns None rather than raising, since a scan skips what it cannot open. This is a correctness fix for cache behaviour, not a latency fix. The multi-second searches that led here were contention, not scan cost: a trivial /api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real sessions measures ~4s total. test_sessions_search_depth_validation patched api.routes.get_session. With the search reading through the scan accessor it now patches get_session_for_scan — left unfixed, two of its cases fail and the third passes vacuously against an empty result set. Validation: pytest tests/test_issue4765_sessions_lru_eviction.py tests/test_sessions_search_depth_validation.py -> 13 passed Both added eviction tests fail on the pre-fix accessor (verified by revert): the working set drops from 4/4 to 0/4 resident after one scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changelog): stamp #6084 content-search working-set preservation --------- Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: pip-installable packaging metadata (#6337, @rodboev) (#6344) * build(#2695): add packaging metadata for the current runtime layout * docs(changelog): stamp #6337 pip-installable packaging metadata --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Live Stream stable Anchor run identity (#6201, @franksong2702) (#6346) * fix: preserve live anchor run identity * Validate envelope run ids before snapshot cursor use * docs(changelog): stamp #6201 stable Anchor run identity --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: fix Kanban column scrolling on mobile (#6306, @jpalazz2) (#6347) * fix: Fix kanban scrolling in mobile viewports Scrolling vertically (particularly in expanded view) in kanban on a mobile viewport is difficult. The columns have overscroll disabled, meaning tap and drag will only scroll within the column and will not continue to the next section. On desktop it's much easier to get the mouse outside the column div, on mobile you have to deliberately try to tap very close to the edge of the viewport. Disabling that behavior makes the experience much better Author: Joe Palazzolo <joe@joepalazzolo.net> * docs(changelog): stamp #6306 mobile Kanban column scroll fix --------- Co-authored-by: Joe Palazzolo <joe@joepalazzolo.net> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * a11y: avoid no-op composer height resets * Release: Live Stream Anchor side-effects projection (#6204, @franksong2702) (#6348) * fix: preserve anchor-owned side effects * test: prove invisible anchor outcomes do not repaint * docs(changelog): stamp #6204 Anchor side-effects projection --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: run-journal summary cache (#6291, @sjungwon03) (#6355) * perf(run-journal): cache unchanged run summaries * fix(run-journal): reject cache after missing-file race * run-journal cache: add st_ctime_ns to signature (close same-size mtime-preserving rewrite window) + regression test [maintainer fix on @sjungwon03 #6291] * docs(changelog): stamp #6291 run-journal summary cache --------- Co-authored-by: sjungwon03 <sjungwon03@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * test: cover composer resize boundaries * Harden historical anchor hydration edges * Release: terminal-error lifecycle gate row (#6354, @franksong2702) (#6358) * test: add terminal-error lifecycle matrix row * test: reject empty terminal process rows --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: gateway-default MoA send (#5869, @rodboev) (#6365) * fix(#5853): allow gateway-default MoA sends * fix(#5853): freeze gateway auth to one locked snapshot * docs(changelog): stamp #5869 gateway-default MoA send --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: raster-data-URI redaction fast-path (#6311, @inch772) (#6367) * fix(redaction): skip native raster data URIs * fix(redaction): accept mixed-case raster MIME types * fix(redaction): validate complete raster payloads * docs(changelog): stamp #6311 raster-data-URI redaction fast-path --------- Co-authored-by: Su Ahn Lee <11433303+inch772@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * feat(extensions): token-v1 proxy→sidecar authentication boundary (#6331) * feat(extensions): token-v1 proxy->sidecar auth boundary Mint a per-extension secret core injects (X-Hermes-Sidecar-Token) on every proxied request; sidecars validate it. Closes the hole where a loopback sidecar port is reachable by any local process and cannot distinguish a proxied request from a direct one. - api/extension_sidecar_auth.py: per-extension token lifecycle (atomic mint, re-read-verified so an unpersisted token is never injected, per-request mtime-cached read for live rotation, path-escape-safe) - manifest proxy_auth negotiation: absent=legacy, token-v1=enforce, unknown=fail-closed - proxy: inject token, strip inbound + response x-hermes-*, auth-off posture (loopback-only local_unprotected, else 503), fail-closed when token unavailable - consent-time auth_required in status payload; mint-on-consent - docs/EXTENSIONS.md proxy_auth section - 7 new tests (26/26 green) * fix(extensions): address Codex+Fable gate on token-v1 (6 findings) - align token-module extension-id grammar with core _EXTENSION_ID_RE (was narrower -> legally-named ext consented then 503'd forever) - resolve token dir dynamically (mirror _extension_state_dir) -> real test isolation, no import-time STATE_DIR cache - cross-process mint: O_CREAT|O_EXCL no-clobber claim (was os.replace clobber) - rotation cache keyed on full fingerprint (ino/dev/mtime/ctime/size) + re-fingerprint after read -> same-size/mtime replacement no longer stale - validate token format on read (url-safe, 16-256) -> malformed file can't leak via a ValueError echoed in a 502 - consent fails 503 when token can't be provisioned (was silent-swallow -> persisted consent then 503 forever) - rename status auth_required -> posture enum (protected|local_unprotected): nothing is blocked for loopback, so 'required' was misleading - panels.js: render local_unprotected warning on the consent row (+ CSS) - docs: token-path resolution order, 401-vs-503, explicit 'legacy' acceptance - tests: route-level token-injection+response-strip test; fix illusory isolation in token-module test; 27/27 green * fix(extensions): close 2 token-mint races (Codex re-gate round 2) - mint via temp-file + atomic os.replace (not O_CREAT|O_EXCL) so the final path is never observed empty/half-written — a concurrent loser can no longer read an empty token file and 503 - single _stable_read helper (fingerprint-read-refingerprint, bounded retry on mid-read change) used by BOTH ensure_token and current_token — a token that changes during the read is never returned or cached stale - stress-verified: 20 concurrent first-mints converge on 1 persisted token; 27/27 * fix(extensions): atomic no-clobber token publish via os.link (Codex re-gate round 3) os.replace fixed empty-file exposure but still clobbered cross-process: two processes could both write+replace and a reader between them got a token no longer on disk -> 401. Switch to the repo's TOCTOU-safe os.link create-or-fail idiom (session_recovery.py:627): write temp -> link into place (fails if a winner already published) -> loser drops its temp and reads the winner via _stable_read. Proven: 16 concurrent PROCESSES converge on 1 persisted token, all matching disk. 27/27 tests green. * fix(extensions): resolve Frank+Greptile #6331 review — token-v1 fail-closed when auth off (consent+resolution), token-v1-only proxy_auth/posture status fields, fullmatch ext-id validator * fix(extensions): finish Frank and Greptile sidecar review --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: compact completed image tool-results (#6315, @sjungwon03) (#6371) * fix(session): compact completed native vision results * streaming: guard image-part compaction against unhashable part.type (isinstance str) + regression test [maintainer fix on @sjungwon03 #6315] * docs(changelog): stamp #6315 completed-image tool-result compaction --------- Co-authored-by: sjungwon03 <sjungwon03@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Preserve historical anchor final text * Release p1-batch: session cache cap 300→100 (#6362) + virtualization comment (#6318) (#6375) * perf(transcript): enable DOM virtualization by default (re-enable #4346 fix) (#6151) (#6155) * fix: revert DOM virtualization default to opt-in, fix gate RED (#6155) * fix(#6351): lower default session cache cap * Release p1-batch: session cache cap 300->100 (#6362) + virtualization comment (#6318) --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: consolidated Kanban board fills vertical space (#6308) (#6376) * fix: Fix height of consolidated kanban board There was a lot of empty space below the kanban board columns in the condolidated view (particularly on desktop). Modify the CSS such that the consolidated view always fills the viewport vertical space. * Release: consolidated Kanban board fills vertical space (#6308) --------- Co-authored-by: Joe Palazzolo <joe@joepalazzolo.net> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: isolate Hermes home per streaming turn (#5877, @starship-s) (#6379) * fix(profiles): isolate Hermes home per streaming turn Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): gate skill isolation by capability Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): harden fallback lock boundaries Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * test(profiles): adapt streaming isolation harness Assisted-by: Codex:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * Release: isolate Hermes home per streaming turn (#5877, @starship-s) --------- Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Kanban New-Task modal reachable on mobile (#6301, @jpalazz2) (#6384) Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev) (#6386) * fix(#6067): keep artifact file names visible # Conflicts: # static/style.css # static/workspace.js * fix(#6067): bound long parent artifact tails inside the drawer * Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev) --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) (#6387) * feat: add option to hide new-chat welcome panel * Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) --------- Co-authored-by: vaidu-ai <im@vaidu.net> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * fix(stream): preserve reader viewport anchor through live-to-settlement collapse Issue #6385: when a streaming turn settles, the two-render sequence (keep-open expanded worklog → collapsed worklog) could displace the reader's viewport because the second render captured its scroll snapshot from the intermediate expanded state, not from the original live DOM. Root cause ---------- The STREAM_DONE handler in messages.js: 1. Arms keep-settled-worklog-open token → renderMessages({preserveScroll:true}) → worklog rendered EXPANDED (height-stable swap preventing shrink jump) 2. Disarms token → _renderMessagesWithScrollSnapshot() → This function called _captureMessageScrollSnapshot() which captured the scroll anchor from the expanded-worklog DOM (step 1 output), then called renderMessages with the worklog COLLAPSED (keep-open gone), then tried to restore from the expanded-state snapshot. The snapshot's semantic anchor (row key, session idx, top offset) was captured from a DOM where the worklog was expanded. After the collapse render the worklog is no longer at that position — anchor keys don't match, the semantic restore fails, and the viewport jumps to a unrelated scrollTop. Fix --- - Capture the scroll snapshot from the LIVE DOM (before any settlement renders) and pass it as to the second render. - Modify _renderMessagesWithScrollSnapshot() to accept a pre-captured snapshot via options._prescro…
Setting the reasoning-effort chip in one WebUI session changed it in
every session. All read and write paths shared a single global key,
agent.reasoning_effort in config.yaml, so the per-session chip was a
global setting with a per-session appearance.
Add Session.reasoning_effort and prefer it wherever the effort is
resolved:
- api/models.py persist the field in the metadata prefix
- static/ui.js send session_id with the chip GET and POST
- api/routes.py GET reads the session value; POST writes it and
evicts the cached agent so the next turn rebuilds
- api/config.py get_reasoning_status() override parameter
- api/streaming.py local agent path prefers the session value
- api/gateway_chat.py gateway path prefers the session value
A session value of None keeps the previous behaviour and falls back to
profile config, so existing sessions, the CLI, and cron are unchanged.
An explicit empty string means "provider default" for that session
only, preserving the nesquena#6219 thinking-toggle re-enable path.
Both request paths are updated because the gateway path reads the same
key; fixing only the local path would leave gateway-routed WebUI chats
globally scoped.
Problem
Z.AI's official API (docs.z.ai) defines two distinct parameters:
thinking: {"type": "enabled"|"disabled"}— the reasoning on/off toggle, supported by GLM-4.5 and above (with GLM-4.7 using forced thinking that cannot be disabled).reasoning_effort— the effort intensity ladder (max/xhigh/high/medium/low/minimal), supported by GLM-5.2 and above ONLY.hermes-webui advertised the full 6-level
reasoning_effortladder (plus thenonesentinel) for all 7 GLM models, because_candidate_supports_reasoninghas an unconditionalglmtoken match (api/config.py:3303, no version gate — unlike the GPT/Claude/Qwen branches above it which check major version) and_filter_reasoning_efforts_for_providerhad no ZAI branch. Six of seven catalog models therefore showed a selector whose values Z.AI documents as GLM-5.2-exclusive, and the chosen value was forwarded toward an endpoint that silently ignores it.Two bugs result:
reasoning_effortadvertised forglm-5.1,glm-5,glm-5-turbo,glm-4.5,glm-4.5-flash(none support it; onlyglm-5.2does).glm-4.7uses forced thinking (cannot be disabled per Z.AI docs), yetnoneand all effort levels were shown for it.Fix (one targeted branch in the existing chokepoint)
Add a ZAI branch to
_filter_reasoning_efforts_for_provider(api/config.py), mirroring the existing OpenAI/Gemini/Anthropic ceiling pattern:Why this is the right scope (per guideline #1 — fix the class, not the instance)
resolve_model_reasoning_effortsis the single source feeding both the UI dropdown options ANDcoerce_reasoning_effort_for_modelclamping (both call through_filter_reasoning_efforts_for_provider). Fixing it here makes the dropdown and coercion agree automatically — no storedmaxwill be degraded incorrectly, because it won't be offered in the first place.max, pre-adaptive Claude drop-max).glmfamily-detection heuristic at line 3303 is deliberately left unchanged — GLM models DO support thethinkingon/off toggle at the family level (that flag drives the thinking-toggle UI too). The bug is specifically about thereasoning_effortintensity ladder, which is what the filter narrows.zaiprovider only (aliasesglm/z-ai/z.ai/zhipuall funnel tozaivia_resolve_provider_alias, which the function already calls). Aggregator providers (openrouter/kilocode/custom) are untouched because they route through their own routers, not Z.AI's native endpoint.Contract Routing
State layer touched:
agent.reasoning_effortconfig (config.yaml) + UI dropdown options derived fromresolve_model_reasoning_efforts.Invariant proof: UI options and coercion now agree and match Z.AI's per-model docs —
max/xhigh/high/medium/low/minimalare offered ONLY for GLM-5.2+ (whose accepted values matchVALID_REASONING_EFFORTSexactly,maxbeing the Z.AI default), andnoneis never offered for forced-thinking GLM-4.7. The downgrade ladder incoerce_reasoning_effort_for_modelis unaffected because it only walks down from levels that ARE in the offered list.Verification
Regression gate satisfied (per guideline #6): 12 of the 24 new tests fail before the fix (the full ladder was returned for every GLM model), all 24 pass after. Verified via stash/unstash on a clean tree.
24 new tests in
tests/test_zai_reasoning_effort_gating.py:none[][](nononeeither)glm/z-ai/z.ai/zhipu) resolve through the same gatetest_generalized_model_families_and_suffixed_idsgreenzaiprovider are untouched by the GLM-specific gatemaxforglm-5.1downgrades,maxforglm-5.2preservesBroader suite: 129/129 pass across
test_zai_reasoning_effort_gating+test_custom_provider_bare_model_reasoning+test_reasoning_effort_model_capabilities+test_reasoning_show_hide+test_catalog_has_provider_compound_ids+test_4413_seed_provider_models. No new lint errors (the 4 pre-existing ruff errors inapi/config.pyreproduce identically on clean master).Pre-existing failure noted (NOT caused by this PR):
test_custom_providers_in_panel.py::test_custom_provider_with_modelsfails under multi-file pytest ordering due to a provider-cache state leak across files — confirmed to reproduce identically on clean master. Passes in isolation.Manual verification I could not do here
glm-5.2and shows nothing forglm-4.7/glm-4.5/glm-5.1(requires UI + running backend).glm-5.2withreasoning_effort: maxsucceeds, and forglm-4.5the field is now omitted rather than silently ignored (requiresGLM_API_KEY+ network).Out of scope — Bug 2 (separate issue)
There is a related gap I deliberately did not address here: no code path in this repo emits the
thinking: {"type": "enabled"|"disabled"}request field for ZAI. That translation lives in the externalrun_agent/agentpackage (_build_api_kwargs()) or the Hermes Gateway server — neither of which is present in this repository (verified:import run_agent/import agentboth raiseModuleNotFoundError; no siblinghermes-agentrepo on disk). Investigating/implementing that belongs in a separate change against the agent tree, not here. Filing a separate issue to track it.Sources