5017 Commits
Author SHA1 Message Date
happy5318 88236dbc26 docs(models): pin empty-allowlist semantics with regression coverage
Greptile flagged that `models: []` / `models: "[]"` falls through the
non-empty allowlist gate and leaves the full live catalog selectable. The
symptom is real, but implementing the ask (gate on declared-ness instead)
measurably makes things worse: it emits an EMPTY picker and leaves the
provider unselectable.

Measured both branches against the real handler before deciding:

  models key absent   declared=False -> [chat-a, chat-b, image-gen-1, audio-tts-1]
  models: []          declared=True  -> []   EMPTY PICKER
  models: "[]"        declared=True  -> []   EMPTY PICKER
  models: [chat-a]    declared=True  -> [chat-a]

An unselectable provider is a harder failure than a few extra visible
models, and it is unrecoverable from the UI: `custom_providers` has no
WebUI write path, so it is hand-edited in config.yaml. There is also no
deny-all use case — a provider the user wants hidden is removed from
`custom_providers` outright, not left behind with an empty allowlist.

No production behavior change. Records the trade-off in the filter block
and adds `test_empty_allowlist_is_treated_as_not_configured` (native empty
list, serialized "[]", empty mapping, blank scalar) so a future "fix" of
this edge case fails loudly instead of silently emptying the picker.

Verified non-vacuous: applying the declared-ness gate fails all 4 new
cases with `assert [] == [...]`.
2026-09-23 16:58:09 +08:00
happy5318 4a1fb47ce5 fix(models): respect config model allowlist in live custom-provider catalog
Custom providers (esp. New-API-style gateways) expose their ENTIRE catalog
via /v1/models, including image/audio models that aren't chat models. The
live-catalog enrichment surfaced all of them in the model picker regardless
of the `models` allowlist declared in config.yaml.

Filter the live catalog down to the explicit plural `models` allowlist, then
append any allowlisted models the upstream didn't return. Only the plural
`models` key gates the filter — a provider configured with just a singular
`model` (sticky/default metadata) keeps the unfiltered live catalog so
discovery still works.

Decode the plural value through `_parse_config_string_list()` so serialized
shapes are honored: `hermes config set` and JSON-mode editor saves persist
lists as quoted JSON-array strings ('["chat-a","chat-b"]') or Python
literals ("['chat-a']"). A dict/list-only parser dropped those to [], which
the caller reads as "no allowlist configured" → full catalog floods the
picker (same class as the skills.disabled regression, #7120/#7134). Native
dict/list values are still walked directly so dict entries keep their
id|model|name metadata, which the decoder would str()-ify.

Tests invoke the real `_handle_live_models` and assert on the emitted
`models` payload, covering: native list, JSON-array string, Python-literal
string, mapping metadata, list-of-dicts, singular-model-only (not gated),
malformed serialized input (safe), allowlisted model missing from live
(appended), and probe failure (config fallback).

Verified non-vacuous: removing the production filter block fails 7 of 9
cases; reverting only the string decode fails exactly the 3 serialized
cases with the documented flood symptom (image/audio models reappear).
2026-09-23 16:58:09 +08:00
starship-s 5fd993cdd5 fix(chat): reconcile conflicting image-row provider payloads
A unique durable row remains one visible row when its sidecar provider payload is stale. Use the state.db payload for replay, cover repeated recovery, and document the image-turn ownership exception.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
2026-09-23 02:50:40 -06:00
starship-s 13552ff567 fix(chat): stabilize recovery without durable row identity
Use exact-key dedup by default and distinguish only rows with two valid different durable IDs.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
2026-09-23 02:50:40 -06:00
starship-s b5596c5acf fix(chat): preserve distinct recovered image rows
Match durable row identity as well as content before skipping a recovered image row, preserving separate same-text turns.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
2026-09-23 02:50:40 -06:00
starship-s e79e6f5cda fix(chat): bound conflicting recovered image rows
Keep conflicting provider payloads distinct, while deduplicating the durable row across repeated reconciliation.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
2026-09-23 02:50:40 -06:00
starship-s 3043909973 fix(chat): recover valid provider sidecars from malformed rows
Treat a malformed provider sidecar as absent when copying a uniquely matched durable payload; retain valid existing payloads unchanged.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
2026-09-23 02:50:40 -06:00
starship-s bf0646f649 fix(chat): avoid reinserting recovered image rows
Reuse the existing unique state-row identity guard before preserving a distinct image projection, so repeated reconciliation does not duplicate the same row.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
2026-09-23 02:50:40 -06:00
starship-s 1f13717ca9 fix(chat): preserve plain-turn matching during settlement
Keep whitespace-normalized text matches for plain Agent rows, retain float timestamps for Gateway turn checkpoints, and test optional attachment suffix behavior rather than source spelling.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
2026-09-23 02:50:40 -06:00
starship-s 2262444806 fix(chat): keep image-turn context out of user bubbles
Preserve the submitted image turn as the display owner while retaining rich Agent context for model replay and suppressing its durable scalar mirror from recovered WebUI transcripts.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
2026-09-23 02:50:40 -06:00
nesquena-hermes 86d5b36898 Merge master (exp-v0.52.349) into stage/7708-media-backtick
# Conflicts:
#	CHANGELOG.md
2026-09-23 08:50:25 +00:00
nesquena-hermes 69655b1fc6 Merge master (exp-v0.52.348) into stage/7749-terminal-handoff
# Conflicts:
#	CHANGELOG.md
2026-09-23 08:37:23 +00:00
Anthony Ruiz 9b6851eaf1 fix(background_process): do not consume the delivery budget on transient wake-up refusals
start_session_turn() answers 409 retryable=True while the Agent runtime is
stale, 409 process_wakeup_paused while wake-ups are paused, and 409 with
active_stream_id while the session is busy with another turn. The bridge
released the durable claim as a plain failure each time, so a burst of
refusals within seconds terminally dropped a completion whose origin session
was alive and waiting.

release_async_delegation_delivery(..., retryable=True) now forwards the
refund to an Agent core whose release_event_delivery() accepts the
`retryable` keyword (NousResearch/hermes-agent#120028), detected through
inspect.signature, and falls back to the historical plain release on an
older core. Hard rejections keep the bounded budget.
2026-09-23 08:32:08 +00:00
Anthony Ruiz 936710dc1d fix(streaming): surface Agent session-lease waits as warnings
When another Hermes process holds a session's turn lease, the Agent
(agent/turn_facade_lease.py) emits lifecycle statuses while waiting and on
admission, and a warn status when the wait times out and the message was
not processed. _agent_status_callback dropped all of them, so a WebUI turn
looked stuck with no explanation.

Classify these notices by Agent status kind (lifecycle/warn) plus the
Agent's exact phrases, and relay them as {'type': 'session_lease_wait'}
warning events rendered by the existing messages.js warning listener.
2026-09-23 08:31:39 +00:00
nesquena-hermes d06ee674a3 Merge master (exp-v0.52.347) into stage/7708-media-backtick
# Conflicts:
#	CHANGELOG.md
2026-09-23 08:29:47 +00:00
nesquena-hermes adda410ec0 Merge master (exp-v0.52.347) into stage/7749-terminal-handoff
# Conflicts:
#	CHANGELOG.md
2026-09-23 08:29:47 +00:00
nesquena-hermes 64106a790d Merge master (exp-v0.52.347) into stage/7751-journal-seq
# Conflicts:
#	CHANGELOG.md
#	docs/rfcs/webui-run-state-consistency-contract.md
2026-09-23 08:28:18 +00:00
happy5318andClaude Opus 4.8 2fbb64bf1c fix(models): require durable terminal evidence before suppressing recovery (re-gate 9/22)
Re-review #7682 (nesquena-hermes 2026-09-22T19:18:00Z, CHANGES_REQUESTED).

`_transcript_already_advanced_past_pending` suppressed recovery on a
transcript-only heuristic: the first assistant row with final visible text
inside the pending turn's boundary. An interrupted multi-segment turn shaped
like

    user -> assistant "Let me check the logs first." (unflagged interim prose)
         -> tool -> tool ... (stream dies here)

passed the predicate as "finished" even though the interim sentence is not a
final answer and the stream was cut off mid-tool. `_apply_core_sync_or_error_marker`
then cleared pending state and returned early: the run journal was never
replayed and no interruption marker was added — a half-finished turn rendered
as complete, journaled tool output never appearing.

Fix: a genuine final answer inside the turn is now NECESSARY BUT NOT
SUFFICIENT. The suppression also requires durable same-stream terminal
evidence — `_run_journal_terminal_state(session, active_stream_id) == 'completed'`
(a `done`/`stream_end` event). Without it, fall through to the normal
journal-recovery path so the partial output is replayed and the interruption
is marked.

Regression tests (red/green verified — both fail with the gate removed):
- test_does_not_advance_when_unflagged_interim_prose_precedes_tools
- test_full_recovery_runs_journal_replay_for_unflagged_interim_prose
  (drives `_apply_core_sync_or_error_marker`, asserts journal replay +
   interruption marker present)
- test_does_advance_when_journal_marked_done_after_interim_prose (positive)

Existing `_FakeSession` fixtures updated to carry a session_id and write a
`done` journal event so the durable-evidence gate is satisfied for the
settled-turn tests.

Verification: test_issue6366 35 passed; anchor_scene + cancelled_turn
neighbours 91 passed. Red/green proven on both regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 15:49:43 +08:00
nesquena-hermes e1c707c6d4 Merge PR #7749 (contributor head bbca954c5c) 2026-09-23 06:24:37 +00:00
nesquena-hermes 41dea5763b test(#7708): point the chunk-boundary assertion at the normalized scan string
The PR moved the MEDIA: scan onto a backtick-normalized copy, so the guard
is now matchEnd===normalized.length. Behaviour is unchanged (Codex drove the
vendored parser across chunk splits); this was a stale source assertion that
made the PR's own CI red on shard 4.
2026-09-23 06:23:21 +00:00
nesquena-hermes a028961133 Merge PR #7708 (contributor head 6455aa9b2b) 2026-09-23 06:22:22 +00:00
nesquena-hermes aeed25b7d0 Merge PR #7748 (contributor head 861a10ec34) 2026-09-23 06:22:18 +00:00
Frank Song 68a068c2e5 fix(run-journal): drop obsolete writer lock state 2026-09-23 13:10:19 +08:00
Frank Song 2bd87a002d fix(run-journal): allocate writer sequence within append transaction 2026-09-23 13:03:31 +08:00
laitekin 987df75568 Merge remote-tracking branch 'origin/master' into fix/7299-hidden-session-poll 2026-09-23 12:23:25 +08:00
laitekin a0e5e32e46 Merge current master while preserving recoverable hidden polling (#7299) 2026-09-23 12:18:23 +08:00
happy5318 6455aa9b2b fix(media): two-pass MEDIA: scan (pre-pass for wrapped, bare class without backtick exclusion) (#7708 round 2)
#7708 re-gate (9/22 23:47), both findings:

**Finding 1 (SILENT)**: A bare path that contains a backtick
(MEDIA:/tmp/report`final.png) is truncated at the first backtick,
so the server stops authorizing it. The round-1 fix traded one bug
for another: adding the backtick to the bare-token exclusion set
dropped the inline-code closing backtick (good) but truncated any
bare path with a backtick in the filename (bad — POSIX and Windows
both allow backticks).

**Finding 2 (SILENT)**: TTS now reads filename fragments aloud
(MEDIA:/tmp/report(1).png -> 'a file).png') because the
parenthesis-exclusion was added to a site that should not need it.
Inline code is already stripped at the top of the TTS flattener.

**Fix shape** (maintainer-suggested two-pass scan):

1. Pre-pass: `MEDIA:([^`\s]+)` -> MEDIA:$1 — strips the
   inline-code backticks off the wrapped form so the bare class
   below sees a plain MEDIA:path.
2. Bare class: MEDIA:([^\s\)\]]) (original, backtick
   removed from the exclusion set) — captures the full filename
   even when the path itself contains a backtick.
3. TTS: restore the original MEDIA:[^\s]+ (no paren/bracket
   exclusion either). Inline code is stripped at the top of the
   flattener so the closing backtick of an inline-code wrapper has
   already been eaten by the time the TTS regex scans the text.

Applied to all 7 capture sites:
- api/routes.py:20453 (_MEDIA_TOKEN_RE, findall site 20513)
- api/media_snapshots.py:507 (media_re, findall site 533)
- static/ui.js:7695 (linkifier, replace site)
- static/ui.js:9142 (TTS flattener — separate shape)
- static/messages.js:4848 (chunk finalize, exec)
- static/messages.js:4896 (combined walk, exec loop)
- static/messages.js:4922 (partial tail, exec)

**Tests** (24 total, all green):
- 11 source-shape guards: each JS/PY site must carry the bare-token
  class literal AND the wrapped-form pre-pass literal
- 4 behaviour tests against the live source: wrapped form strips
  closing backtick, bare form captures plain path, bare form with
  backtick in filename captures the full path (the new regression),
  trailing paren is still excluded
- 2 round-1 regression guards documenting the trade-off that drove
  the two-pass compose
- 1 composed two-pass test (pre-pass + bare class on 4 inputs)
- 2 node --check syntax guards
- 1 site-count guard: 5 JS + 2 PY = 7 capture sites, all guarded

**Verification**:
- pytest tests/test_issue7359_media_backtick_strip.py -v -> 24/24 pass
- python3 scripts/ruff_lint.py --diff origin/master -> 'no new
  violations on added/modified lines. OK.'
- node --check on the two modified JS files -> both OK
2026-09-23 12:03:33 +08:00
Frank Song bbca954c5c fix: gate idle recovery on exact runtime status
(cherry picked from commit 35597715018e598bf24f0b708cbd32b039533a99)
2026-09-23 11:57:31 +08:00
Frank Song ca2153ec54 fix: bound sidebar idle recovery for missing terminal events
(cherry picked from commit 1be0e114b32eefe14f94837ed3cf78aadc14ca9b)
2026-09-23 11:57:31 +08:00
Frank Song 5dcda6f537 fix: preserve terminal handoff across idle sidebar refresh
(cherry picked from commit 85f1c2d34b62b0ade85f51ed2cf8c1cf56f5ab9e)
2026-09-23 11:57:31 +08:00
Frank Song 861a10ec34 fix: preserve successor ownership of reused Agents
(cherry picked from commit e4037f1d785aa86074e7cc84025942da5c0cc13a)
2026-09-23 11:56:10 +08:00
Frank Song acdcddf5c9 fix: make Agent cache publication atomic with Stop
(cherry picked from commit c40c43e0eee76a829b113d0119a476a651b60125)
2026-09-23 11:56:10 +08:00
Frank Song 281e91aca3 fix: publish newly constructed agents only after Stop admission
(cherry picked from commit 84a12254fc36aaff628b7e8e43daf48dfd648e67)
2026-09-23 11:56:10 +08:00
Frank Song 9b3951583d fix: fence self-heal worker registration against cancellation
(cherry picked from commit 0fb38b7b5c9453781353464ca5c48c7365359cf1)
2026-09-23 11:56:10 +08:00
nesquena-hermes fd0842cc4d Merge #7742 2026-09-23 03:51:06 +00:00
nesquena-hermes 671905b89d Merge #7744 2026-09-23 03:51:05 +00:00
happy5318 8d0cdaefbe fix(i18n): preserve explicit saved English on first-visit bootstrap
Closes #7622.

Round-3 follow-up to the maintainer review at
https://github.com/nesquena/hermes-webui/pull/7730#issuecomment-5786493511.
The round-2 'primary === "en"' heuristic overrode users who had
genuinely picked English: when the server-stored 'language' was 'en'
the resolver treated it as the schema default and skipped it, falling
through to localStorage / the browser hint.  This round addresses all
three maintainer findings:

1. BRICK — server stops injecting the schema default.  Drop
   '"language": "en"' from api/config.py:_SETTINGS_DEFAULTS so a
   fresh install returns settings['language'] == None.  Existing
   settings.json files that already carry 'language: "en"' still
   report 'en' (the field is just absent from the defaults dict; the
   per-file load path is unchanged).  'language' is added back to
   _SETTINGS_ALLOWED_KEYS so the Settings modal save path still
   validates and persists explicit user picks (the BCP-47 regex is
   unchanged).

2. CORE — browser-language detection is now in one guarded helper.
   static/i18n.js exposes _detectBrowserLanguageHint(), used by
   loadLocale(), boot.js, and panels.js.  A throwing navigator
   accessor in some embedded webviews can no longer abort boot or
   settings hydration before the model/provider/plugin/extension
   sections are populated.

3. CI RED — restore the test shape.  boot.js and panels.js call
   resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'),
   _detectBrowserLanguageHint()) inline so
   tests/test_language_precedence.py::test_boot_and_settings_panel_use_shared_locale_precedence
   matches the expected argument list.  The TestComposedResolverPrecedence
   tests in tests/test_issue7622_initial_locale_from_browser.py are
   re-pinned to the round-3 contract (server primary is None for a
   fresh install; an explicit 'en' primary from a user-saved file
   beats a non-English browser hint).

Tests (22/22 i18n-related pass; 833/833 settings+i18n+locale pass; ruff
forward-gate --diff origin/master is clean):
- test_language_precedence.py: 8/8 (added guarded-helper, server-default,
  composed-resolver, and cross-file first-visit tests)
- test_issue7622_initial_locale_from_browser.py: 14/14 (re-pinned to
  round-3 contract; added explicit-saved-English regression test)
- test_login_locale.py and friends: unchanged behaviour for the
  save/load round trip
2026-09-23 11:21:51 +08:00
nesquena-hermes 394ff790e5 Merge master (exp-v0.52.344) into stage/7436-reconnect-replay
# Conflicts:
#	CHANGELOG.md
2026-09-23 03:21:30 +00:00
nesquena-hermes c5663e86a7 Merge PR #7436 from atchisonbrent: skip unchanged tool-card replay on reconnect 2026-09-23 02:29:39 +00:00
nesquena-hermes a6d6b3da85 Merge PR #7374 from Thireus: fix duplicate assistant answer render (#2051) 2026-09-23 02:28:29 +00:00
webtecnica 67135b3b1e test(models): pin live-rebuild budget in model-cache metadata tests
The three get_available_models() tests in tests/test_model_cache_metadata.py
depend on the rebuild finishing inside _LIVE_REBUILD_BUDGET_SECONDS (4.0s).
On a loaded host the rebuild exceeds it and api/config.py::get_available_models
serves the over-budget stale payload, failing the rebuilt-metadata assertions.

Pin the budget to 0.0 (synchronous rebuild), the same pattern already used by
tests/test_issue2513_custom_provider_remote_models.py and
tests/test_copilot_provider_model_settings_not_allowlist.py.

Closes #7735
2026-09-22 22:58:20 -03:00
webtecnica a8180bc51f fix(context-length): guard provider ownership on global model.base_url backfill (#7535)
A session on a built-in registry provider (e.g. opencode-go, endpoint in
PROVIDER_REGISTRY, empty base_url by design) had its empty base_url slot
backfilled with the global model.base_url owned by a *different* provider
(custom:ark). The context-length resolver then probed the wrong endpoint's
/models catalog and fell back to the generic 128K catalog entry for a
1M-context model, while the on-disk session and SSE stream carried 1,000,000.

Only fill the global model.base_url slot when the session provider IS the
configured model.provider owner, mirroring the ownership predicate used for
model_cfg's API key in _context_length_config_api_key_for_provider.
2026-09-22 22:54:12 -03:00
webtecnica 26702fd19f Remove flaky source-text oracle test (issue #7726)
test_delete_cli_session_still_opens_writable_connection asserted on
inspect.getsource(models.delete_cli_session) text, so it failed only in
single-process full-suite runs when an earlier test leaves a
monkeypatched/decorated wrapper bound on api.models.

The writable-connection contract is already covered end to end by
tests/test_issue1494_state_db_fd_leak.py::test_delete_cli_session_closes_connection,
which performs a real delete and asserts the rows are gone. Remove the
source oracle without a textual replacement; keep a short comment
pointing at the behavioral coverage.
2026-09-22 22:49:23 -03:00
nesquena-hermes 8a2d79ca43 Merge PR #7734 (rebased onto master; maintainer conflict resolution + follow-ups) into stage/rm-343-batch 2026-09-23 00:43:14 +00:00
nesquena-hermes 087a2df4a2 Merge PR #7716 into stage/rm-343-batch 2026-09-23 00:43:14 +00:00
nesquena-hermes d79640f6b1 Merge PR #7631 into stage/rm-343-batch 2026-09-23 00:43:13 +00:00
nesquena-hermes 090728c41d test(#7520): teach the #7514 projection tests the one glm-4.5-flash remap slot
The #7514 tests pinned a pure namespace projection (same ids modulo zai/ ->
z-ai/, same labels). #7520 intentionally serves z-ai/glm-4.5-air in the
glm-4.5-flash slot because OpenRouter does not carry glm-4.5-flash. Encode that
single explicit remap in the reference translation; keep order, length and
every other label pinned.
2026-09-23 00:13:11 +00:00
happy5318 b471e7d1b8 fix(i18n): fall back to browser language on first visit (#7622)
Closes #7622.

The renderer's `loadLocale()` was strictly a localStorage-or-English
fallback — non-English speakers had to dig into Settings after every
fresh install.  This change consults `navigator.languages[0]` (or
`navigator.language` when the ordered list is missing) ONLY when no
stored preference exists, so:

  - first visit on a zh-CN browser → UI lands on `zh` immediately
  - first visit on an en-US browser → still `en` (regression guard)
  - first visit on a kl-GL browser (unsupported) → falls back to `en`
  - any second visit (or after the user picked a language in
    Settings) → the stored value wins, the browser hint is never
    consulted again for that browser profile

Tests (8/8 pass): a node driver loads the real `static/i18n.js`,
mocks `localStorage` and `navigator`, and asserts the language
that `loadLocale()` ultimately passes to `setLocale(lang)`.  The
test covers the four core happy/sad paths plus two pinning tests for
the 'stored wins' contract.

`resolveLocale()` already strips the region subtag (zh-CN → zh,
en-US → en, fr-FR → fr), so the `navigator.language` value is
reduced to a key in `LOCALES` without any extra work in this PR.
The fix is strictly additive: `loadLocale()`'s precedence chain
becomes (navigator hint) → (stored) → 'en' on first visit, and
remains (stored) → 'en' on every subsequent visit.
2026-09-23 07:59:27 +08:00
nesquena-hermes e3c21a386c fix(#7520): drop the '(free)' label from z-ai/glm-4.5-air and make the regression test non-vacuous
OpenRouter bills z-ai/glm-4.5-air as a paid model (0.13/0.85 per 1M), so the
wizard must not label it free. Since #7514 the projection offers the dead id
as z-ai/glm-4.5-flash, not zai/glm-4.5-flash, so the original assertion passed
on master; assert the z-ai spelling (fails pre-fix, passes post-fix).
2026-09-22 23:56:38 +00:00
Muhammad Usama d5349a169d fix: resolve #7520 — remap dead zai/glm-4.5-flash to z-ai/glm-4.5-air in OpenRouter wizard 2026-09-22 23:55:21 +00:00