Commit graph

7585 commits

Author SHA1 Message Date
nesquena-hermes
10e5c52b10
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>
2026-07-19 22:08:29 +00:00
Frank Song
223427741f Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-20 02:02:13 +08:00
nesquena-hermes
4e768fe247
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>
2026-07-19 17:41:07 +00:00
Frank Song
7e0582bfcf Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 12:30:02 +08:00
Frank Song
deac1384fe
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>
2026-07-18 21:23:25 -07:00
Frank Song
abdb0cbba0 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 12:00:03 +08:00
nesquena-hermes
31d4281a19
Release: optional Transparent Stream event timestamp chips (#6130, @rodboev) (#6300)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-18 20:58:41 -07:00
Frank Song
60017c76a2 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 11:58:29 +08:00
Rod Boev
5771a511e7
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.
2026-07-18 20:56:44 -07:00
Frank Song
8ff17ea7bb Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 11:18:17 +08:00
nesquena-hermes
17c2f2086c
Release: bg-process restart recovery (#6287) + Windows test-fixture symlink fallback (#6276) (#6294)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-18 20:10:01 -07:00
allenliang2022
ce048b0a5b
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.
2026-07-18 20:07:54 -07:00
Frank Song
bf961cea9d Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 11:02:47 +08:00
Rod Boev
704e19adfe
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.
2026-07-18 19:56:06 -07:00
Frank Song
0ae40dcd98 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 10:42:31 +08:00
nesquena-hermes
6ad84d7dd3
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>
2026-07-19 02:26:55 +00:00
Frank Song
1ae6b7973a Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 09:02:19 +08:00
nesquena-hermes
c67fd2dd27
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>
2026-07-18 17:53:57 -07:00
Frank Song
f4a9abb662 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 08:01:59 +08:00
nesquena-hermes
b324eb91e2
Release: prevent bare-id picker revert on empty provider hint (#6199, @webtecnica) (#6280)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-18 16:57:28 -07:00
webtecnica
0ef23c9c5e
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>
2026-07-18 16:55:43 -07:00
Frank Song
3d38047c2f Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 07:32:01 +08:00
nesquena-hermes
2ea0cc6ed2
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>
2026-07-18 23:24:23 +00:00
Frank Song
44f470efff Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 05:27:05 +08:00
nesquena-hermes
94514458d9
docs(changelog): stamp v0.52.76 stable section (promotion) (#6269)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-18 21:25:26 +00:00
Frank Song
9243b08922 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 05:16:30 +08:00
Frank Song
fd48f26162 Fail closed on historical anchor hydration throws 2026-07-19 05:16:06 +08:00
nesquena-hermes
be45c396ed
Release: render data:image URIs as images (#6209, @ai-ag2026) (#6270)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-18 14:15:26 -07:00
Manfred + TARS
805db145fc
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>
2026-07-18 14:10:28 -07:00
Frank Song
cd3fb5b14f Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 03:59:37 +08:00
nesquena-hermes
bbb5446bd6
Release: deduplicate configured model badges (#6221, @happy5318) (#6268)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-18 12:48:50 -07:00
happy5318
76690c66e5
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>
2026-07-18 12:43:43 -07:00
ai-ag2026
a248aa52de 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>
2026-07-18 20:47:03 +02:00
ai-ag2026
0dc16dcce8 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>
2026-07-18 20:32:56 +02:00
Frank Song
cf66ad659d Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 02:32:45 +08:00
nesquena-hermes
69ffaf3443
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>
2026-07-18 11:21:22 -07:00
Frank Song
50478131c1 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 02:01:40 +08:00
nesquena-hermes
c9deb980c1
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>
2026-07-18 10:57:35 -07:00
Frank Song
1929cbd54d Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-19 01:42:49 +08:00
nesquena-hermes
c4284529b0
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>
2026-07-18 10:36:17 -07:00
Frank Song
a86d180653 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-18 23:39:10 +08:00
nesquena-hermes
0a31a4a1e2
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>
2026-07-18 08:35:35 -07:00
Frank Song
5c4f38a518 Merge remote-tracking branch 'origin/master' into paperclip/pr-6222 2026-07-18 20:54:27 +08:00
nesquena-hermes
d195b094bb
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>
2026-07-18 05:44:33 -07:00
ai-ag2026
96bb78b1c1 test: give ctl start fixtures startup grace 2026-07-18 13:53:22 +02:00
ai-ag2026
5d3b76bd13 test: allow startup monitor cleanup in dotenv test 2026-07-18 13:53:21 +02:00
ai-ag2026
7ab9f6ee5c fix: bypass proxies in ctl startup health probe 2026-07-18 13:53:21 +02:00
ai-ag2026
ee7af85884 fix(ctl): keep listener diagnostics best-effort 2026-07-18 13:53:21 +02:00
ai-ag2026
1272ccb4eb 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>
2026-07-18 13:53:21 +02:00
ai-ag2026
6f10e4d48b 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>
2026-07-18 13:53:21 +02:00