The browser vault now draws from three login sources behind one handle
shape: the local encrypted vault (vault_…), 1Password Login items (op:…)
and Bitwarden Password Manager logins (bw:…). browser_vault_list aggregates
metadata across them; browser_vault_fill routes by prefix and resolves the
password at fill time only, through the manager CLI.
External managers are locked until the user unlocks them for the current
session. The new browser_vault_unlock tool (and the fill path, implicitly)
asks the surface to show a masked master-password prompt — CLI panel
(reuses the sudo panel state), TUI/Desktop via a vault.unlock.request
blocking card. The password goes to `op signin --raw` / `bw unlock --raw`
on stdin, never argv or env; only the session token is kept, in memory,
with a 30-minute idle TTL, cleared on session close or `vault.lock`.
Headless contexts (cron, webhook, api_server, -q) can never prompt: the
manager is reported as locked with unlock=unavailable_in_this_session and
fill refuses — the same posture approvals take where nobody can answer.
Config: vault.onepassword / vault.bitwarden {enabled, binary_path, …};
a 1Password service-account token skips the prompt for headless use.
RPC: vault.sources, vault.source.set, vault.unlock, vault.lock for Settings.
Tests (2, real subprocess against a fake bw; each proven red by sabotage):
headless never prompts or spawns; unlock feeds stdin only, token never
enters os.environ, fill routes by prefix and the password only reaches the
fill script.
Consolidated re-apply of #96988 onto current main. Ported from
Merit-Systems/OpenInstinct (MIT) opaque-handle autofill design: the model
sees vault handles + login metadata, the password is resolved and filled
server-side over the supervised CDP socket, and filled values are scrubbed
from every browser tool result by an unconditional redaction registry.
Rebase adaptations to the Sep-2026 facade/sibling layout:
- toolsets: one _HERMES_CORE_TOOLS entry (the browser toolset derives from it)
- hermes_cli/main.py: vault parser registered via the subcommand owner table
- file_safety: vault/ joins the _READ_DENIED_DIRS credential-dir table
- redact: registry scrub runs before the redact_secrets early-return
- browser_vault_tool: _run_browser_command now lives in browser_tool_session
The facade forwarded turn_author= to the loop's public entry point, which did not
declare it: every real AIAgent.run_conversation() turn raised TypeError (16 CI
failures across provider, sidecar, cron and finite-chat suites). The PR's tests
only exercised build_turn_context directly, so the missing hop was invisible.
Adds one facade-through-loop test that goes red when the kwarg is dropped.
Keep one or two behaviour tests per seam (author reset on a cached agent, forged
_turn_author refused, guard trips and cools, single charge on the busy path) and
drop the parser/setting enumerations. a2a_key goes with them: nothing in this PR
reads it; the honcho follow-up that does can bring it back with its consumer.
bot_relay.deliver accepted from_profile, from_handle and from_connection from any admitted JSON-RPC client. The handler now refuses them with error 4095 when the calling transport carries a browser login identity, since a logged-in browser never relays for another connection. The DeliveryAuthor docstring now says the author is trusted because an admitted client relays it, not because the sender is verified.
message_agent sent a bare bot:<profile> id through hermes peer dm, so a remote coder and the recipient's own coder shared one author id. The peer branch now sends bot:<hostname>/<profile>, with the hostname cleaned like any author field and slashes dropped. The direct local path keeps the bare id.
The author travels on the queued entry and the _run_prompt_submit call, and turn_context resets agent._turn_author at every turn start. The test pins that the human prompt following a drained relayed dm reaches run_conversation without a turn_author.
delivery_turn_author kept the bare bot:<profile> id when the sender's connection was the Desktop's own "local", so a DM relayed from that machine collided with the recipient's profile of the same name. A relayed DM always crosses gateways, so the connection id is now part of the id whenever the Desktop sends one, and only the direct message_agent path in tools/bot_mode_dm.py stays bare. The relay.ts and session_auto_continue.py comments added earlier are cut to one line each.
A relayed DM stamped bot:<profile> on the recipient turn, so an ops profile on another machine and the local ops profile shared one author id. The Desktop now forwards from_connection with each bot_relay.deliver, and delivery_turn_author builds bot:<connection>/<profile> for it while the Desktop's own gateway ("local") keeps the bare id. An api author object accepts an optional origin string that yields the same shape.
MemoryManager.on_turn_start forwarded the author kwargs to every provider and _each_provider swallowed the TypeError, so a provider with the two-positional on_turn_start(n, text) stopped running. The kwargs are now filtered against the provider's signature the way _provider_sync_accepts filters sync_turn.
_enqueue_prompt dropped a queued entry whose text matched the live prompt without checking turn_author, and _sanitize_queued_entry_vs_inflight_user stripped the inflight prefix from any text entry. A relayed DM carries its sender, so an entry with turn_author is never treated as a self-duplicate and never prefix-stripped.
The loop guard peeked under the transport profile stamped on the source but counted under the routed profile's scope, so the two read different gateway.bot_loop_guard blocks. _admit_bot_message_for_source runs the count under the same _authorization_profile_home as _is_user_authorized_for_source, and both the ingress gate and the busy path call it.
_handle_active_session_busy_message only peeked at the guard, so a bot steering a running turn was never counted and the budget never tripped. The busy handler now admits the message once after the authorization verdict and marks the event. _hm_admit_event skips a marked event, so a follow-up that was queued on the busy path and drains later is not charged twice.
When the target Bot Chat is already open on this gateway, the relay handler delivers through
`prompt.submit` with `queued: true`, and that branch dropped the envelope's sender. The model
still saw the text prefix, but the turn reached the agent unattributed, the exact case the
subprocess branch fixes.
The relay handler now stamps the author on the submit as a `DeliveryAuthor`, an in-process object
a JSON client cannot build, so `prompt.submit` accepts it the way it accepts a hosted-room callback
and refuses a dict with error 4124. The busy queue keeps an authored envelope in its own slot, the
drain hands the author to the turn runner, and the runner passes it to an agent that declares the
keyword. A plain prompt after an authored dm carries no author.
Local deliveries to a desktop-owned Bot Chat take the live-owner mailbox instead. The admission
intent and the mailbox record now carry the author, a retry under the same id with a different
author is refused, and the owner gateway hands the author to the turn it runs. Isolated compute
turns still run unattributed, because the compute-host frame has no author field.
The gateway turn runner and the quiet one-shot passed `turn_author=` unconditionally. Every
test double and wrapper with an older `run_conversation` signature raised TypeError, which CI
caught across twenty gateway tests. Both call sites now check the callee with the existing
`_accepts_keyword` helper. A human `-Q` turn keeps today's call shape with no author keyword.
The Telegram adapter asks the authorization check before dispatch, the ingress gate asks it
again, and the busy path asks a third time. Each call counted one loop-guard event, so a
Telegram bot tripped the budget after a third of the configured messages. The verdict now only
refuses a chat that is cooling down. The ingress gate counts an admitted bot message once.
`parse_turn_author` treats only booleans, integers and the strings true/1/yes as a bot flag,
and returns None for an author with neither id nor name. Names keep format characters and
non-breaking spaces so emoji sequences survive. The quiet one-shot pops HERMES_TURN_AUTHOR
before the turn so tool subprocesses do not inherit it. `max_events` must be a whole positive
number. Issue numbers move out of code comments.
#91483 added the guard inside the ALLOW_BOTS block. on today's authz layout
an admitted bot returns from an earlier rule before that block runs, so the
guard never tripped (its own ping-pong test passes turn 21 on this tree).
_is_user_authorized now computes the allowlist verdict first and runs every
admitted bot-authored message through the guard, so bots admitted by a chat
allowlist, allow-all, pairing or delegation are metered too. the budget stays
per conversation. settings move from HERMES_BOT_LOOP_* environment variables
to config.yaml gateway.bot_loop_guard (defaults added to config_defaults),
re-read on every call. an unauthorized bot dm no longer gets a pairing code,
so a cooldown produces no outbound traffic. tests rebuilt against real
SessionSource objects.
Telegram Bot API 10.0 (2026-05-08) lets bots receive messages from other
bots, and the platform ships no loop guard of its own. core.telegram.org
/api/bots/bot-to-bot ("Loop prevention") requires the BOT to make
bot-message handling terminate predictably via dedupe, per-chat rate
limits and maximum interaction depth, and warns that "failure to handle
loops properly may lead to degraded performance or platform
restrictions".
Hermes had no brake at all: TELEGRAM_ALLOW_BOTS in gateway/authz_mixin.py
was a bare `return True` with no counter, window or cooldown.
TELEGRAM_ALLOW_BOTS=mentions does not help, because when bot A replies to
bot B the reply itself satisfies the mention test, so every turn re-arms
the peer. Observed 2026-08-21: two Hermes bots exchanged 132 messages in
one group before a human intervened.
This adds gateway/bot_loop_guard.py, a thread-safe sliding-window budget
with cooldown and sweeping of stale buckets, wired into
_is_user_authorized. Defaults (20 events / 60 s window / 60 s cooldown)
match the OpenClaw reference implementation.
Two placement details that are easy to get wrong:
- The guard runs BEFORE the TELEGRAM_GROUP_ALLOWED_CHATS shortcut. That
env var returns True for any sender in an allowlisted chat, bots
included, ~32 lines before the is_bot block, so a guard placed in the
is_bot block never executes for the one configuration that needs it.
- The budget is keyed per CONVERSATION, not per (sender, receiver). This
process only sees inbound messages, so the receiver is constant; keying
on the pair would give each sender its own budget and N bots would need
N x budget messages to trip a guard meant to cap the whole exchange.
Installations without ALLOW_BOTS are byte-identical in behaviour: the
guard only runs on the source.is_bot branch with ALLOW_BOTS set to
mentions or all. HERMES_BOT_LOOP_PROTECTION=off is a full kill switch.
Adds tests/gateway/test_bot_loop_guard.py (7 cases), each verified
failing against the tree without this patch.
POST /api/sessions/{id}/chat, /chat/stream and /v1/runs take an optional
"author" object. absent or null keeps today's behavior; a non-object is a 400
invalid_author. the value reaches run_conversation as turn_author and nothing
else: it is a claim by an API-key holder and grants no capability.
hermes peer dm and peer run add the author to the request body when the
message_agent runner launched them with HERMES_TURN_AUTHOR set, so a
cross-machine dm is attributed the same way a local one is.
a bot dm arrived as an ordinary user message. the only trace of the sender
was the "Message from" text prefix, which the model reads and nothing else
does. the recipient's memory provider saw its own configured user.
message_agent now passes the sender as {"id": "bot:<profile>", "name":
<handle>, "is_bot": true} to the delivery runner (--author <json>), which sets
HERMES_TURN_AUTHOR on the recipient one-shot only. the -Q turn reads it and
passes turn_author into run_conversation. the desktop relay forwards the
envelope's from_profile/from_handle to bot_relay.deliver, which sets the same
variable on its delivery turn. the runner drops any inherited author first so
a delivery without one stays unattributed. the text prefix is unchanged.
_extract_cache_busting_config knew one memory provider by name and imported
its config class to read identity keys. it now also asks the configured
provider for identity_signature() through plugins.memory.load_memory_provider
and merges the result under "memory.<key>". the hardcoded block stays until
that provider implements the hook.
on_turn_start already received the author trio. sync_turn did not, so a
provider that wanted to write the turn under its author had to stash state
between the two hooks. sync_turn now takes turn_author as a keyword-only
argument, and MemoryManager sends it only to providers whose signature
accepts it, so existing providers keep working unchanged.
build_turn_context resets the author on the agent at the start of every turn
so a cached gateway agent never carries a bot author into the next human
turn. agent/turn_author.py holds the parsing and the HERMES_TURN_AUTHOR
carrier.
MemoryProvider.identity_signature() is a new optional hook: the identity
values a provider writes under, declared by the provider itself, for the
gateway's agent cache to key on.
`on_turn_start` documents a per-turn kwargs channel — "kwargs may include:
remaining_tokens, model, platform, tool_count" — and `MemoryManager`
forwards whatever it receives. Its only caller passed nothing, so a memory
provider had no way to learn who wrote the turn it was being told about.
Providers that key durable state on identity resolve one identity when the
session is created. A shared session does not work that way: threads are
shared by default (`thread_sessions_per_user` is False), so alice, bob, and
another agent all write turns into a session whose peer is whoever spoke
first. The gateway's answer today is the `[name]` prefix it prepends to the
message text, which the model reads and a provider cannot.
`turn_author` now travels from the gateway through `run_conversation` into
`build_turn_context`, which forwards `author_id`, `author_name`, and
`author_is_bot` to every provider. It stops there — the trio never reaches
the model, and providers that ignore the kwargs are unaffected.
The bot flag is sent on every transport, not only shared sessions: a
provider deciding whether a turn may write to durable memory needs it in a
DM too.
`SessionSource.is_bot` is only as good as its producers. `build_source`
defaults it to False and 3 of 32 adapter call sites pass it, so most
platforms still report every author as human. Populating the rest is
follow-up work; nothing here depends on the flag being right yet.
Subagent list/tail/steer/interrupt authorized against a per-record copy of
the owning session's transport (`owner_transport`). That copy had to be
re-synced at every reattach site; `_rebind_live_transport` did it for
session.resume/activate but prompt.submit and the queued-prompt drain still
attached bare, so a client that reconnected through a prompt (the common
path on a remote gateway / Bot Mode switch) streamed fine while
`subagent.list` returned [] and controls rejected.
Read `owner_session_record["transport"]` at check time instead: the slot is
already mutated by every attach/detach/viewer-failover path, so no site can
forget the sync. `owner_transport` stays as the capture-time "commissioned
by a gateway session" marker (None = no RPC authority ever); non-dict owners
keep the exact-object rule. Drops the registration-time re-read and the
attach-time registry loop.
Diagnosis credit: nftpoetrist (#106663) — their prompt.submit / drain
regression tests pass against this change with no call-site edits.
The waiter budget test read relay.ts with a regex, which AGENTS.md bans and which the Python CI lane would not rerun on an apps/-only PR. It now checks DESKTOP_DELIVER_TIMEOUT_SECONDS against the module's own constants and that REPLY_WAIT_SECONDS exceeds it. relay-deliver-budget.test.ts still pins the TS mirrors against those Python constants from the Desktop side.
The sender-side waiter gave up at 900s while the Desktop held bot_relay.deliver open for 1500s, so a turn finishing between minute 15 and minute 25 wrote a reply nobody read. REPLY_WAIT_SECONDS now rebuilds the Desktop budget from the same numbers and waits 60s past it. The two turn constants move into tools/bot_relay.py so the gateway handler and the waiter share one definition.
Three defects found in review of the first head (@ehz0ah):
* The mid-request hop read the PERSISTED provider from disk. A live
`/model xai-oauth` session over `model.provider: auto` therefore still
reached the discovery chain and billed Nous. _try_payment_fallback now
takes the route's main_runtime snapshot; disk is the fallback only when
no session runtime exists.
* After a configured fallback was quarantined mid-request (401, refresh
failed), the second pass went straight to the discovery chain, which
the new gate refuses — so later CONFIGURED entries never ran and the
original error was re-raised. The second pass now re-walks the task
chain and main chain (the quarantined entry is unhealthy and skipped)
before discovery.
* current_provider_owns_vendor dropped ids detect_vendor could not
classify, so Bedrock's 15-id catalog (14 unclassified `us.anthropic…`)
looked exclusively DeepSeek and `/model deepseek-v4-pro` stuck on
Bedrock. An unclassified id now counts as evidence of a multi-vendor
catalog: ownership requires every id to classify to the one vendor.
With a main provider selected, an unusable main route (expired xAI/Codex
OAuth token, 401/402/429 mid-session) fell through the built-in discovery
chain (OpenRouter -> Nous -> custom -> api-key) and quietly ran every
compression, title and memory-flush call on whichever OTHER account was
still logged in. Reported as "using Grok on my Premium+ sub, my Nous
Portal balance kept draining" — the chat visibly stayed on Grok while the
side tasks were billed elsewhere, and re-logging into X did not help
because the aux side never consulted the selected provider.
The discovery chain is now reserved for installs with no selected main
provider (`model.provider: auto` / unset). Otherwise the ladder is
main -> auxiliary.<task>.fallback_chain -> fallback_providers -> refuse
with a warning naming the dead provider and the fix. Both entry points
gate on the same predicate: the resolve-time route and the mid-request
payment/auth hop (_try_payment_fallback).
Existing chain tests that asserted the hop now pin `provider=auto`, the
one case where discovery is still the contract.
Capabilities → Plugins is now a single table: one row per PACKAGE, with a
Desktop column (this app) and an Agent column (the selected profile). A
package with both halves is one row, never two; the kind badge is inferred
from what it ships (plugin.yaml → agent, plugin.js → desktop).
The desktop half of a unified agent+desktop package no longer loads from the
profile-shaped `plugins/<name>/desktop/` folder. Electron copies that half
into `~/.hermes/desktop-plugins/<name>/` beside a `.hermes-package.json`
marker (package name, source, origin repo/sha) and keeps it in sync: newer
source → re-copy, package uninstalled → copy removed, hand-installed
standalone folder of the same name → never overwritten. The renderer scans
exactly one root, so a pane can never appear, disappear, or re-scope when the
user switches profiles — the same switch reads the same value everywhere.
Why a copy rather than scanning every profile: two profiles can carry the
same package at different SHAs; a scan has to pick one silently. One copy,
one source of truth, stamped with where it came from.
- plugin-packages.ts: pure merge of desktop records + agent rows → rows
- plugins.manage list reports `has_desktop_half` so the pairing is explicit
- Install dialog (local backend): the desktop half is materialised from the
installed package instead of cloning a second standalone copy; remote
backends keep the separate clone. Target path now names the real profile
folder for non-default profiles.
- "Install here": a desktop half whose agent half is missing in the selected
profile pre-fills the dialog from the marker's origin; disabled with an
explanation for hand-copied folders with no origin.
- Profile selector moves into the Agent column header; hidden with 1 profile
- Rescan/Update reconcile the copies BEFORE rescanning (ordering bug)
- Drop the dead `agentPluginsRoot` IPC; docs updated (desktop.md, SDK,
bot-mode.md, hermes-desktop-plugins reference)
Live-dogfooded on a headless Electron with two profiles and a real
file:// git package: install both halves, profile switch ×3, Install here
into the second profile, v2 update via `hermes plugins update` → chip text
changes on Rescan, uninstall from both profiles → copy and row gone, broken
plugin row, cold restart, sash drag/reset, legacy Settings → Plugins
redirect.
Only the CLI could install a plugin at an exact commit (`--ref <sha>`); the
Desktop "Install from Git" dialog, the TUI gateway `plugins.manage install`
method and the dashboard `/agent-plugins/install` endpoint all called
`_install_plugin_core` without a ref, so a team that wanted everyone on the
same private-plugin commit had to leave the app for a terminal.
- `dashboard_install_plugin(ref=)` threads the pin to `_install_plugin_core`;
the 40-hex validation and HEAD verification are unchanged. The gateway
method and dashboard body accept `ref`.
- Desktop dialog gains an optional "Pin to commit" field (custom sources only,
client-side 40-hex check disables Install on anything shorter).
- `plugins.manage list` rows carry `pinned_sha`; the Desktop plugins tab shows
a `pinned @ <sha8>` badge and `hermes plugins list` prints `git pinned@<sha8>`
in Source, so a team can eyeball that everyone runs the same commit.
The deepseek provider allow-listed ids by shape (two canonicals plus a
^deepseek-v<N> regex) and rewrote everything else to deepseek-flash. That
swallowed the vendor's own deepseek-flash the day it shipped (#107206) and
would do the same to any future id without a v<N> marker, while still
letting shape-matching guesses such as deepseek-v4.1-flash through to a 400.
Only the two ids DeepSeek actually retired (deepseek-chat, deepseek-reasoner)
are remapped now; every other id the user typed reaches the wire as typed and
the API's own error names the valid models.
session/set_model with "anthropic:claude-sonnet-5" while already on anthropic
parsed to the same provider, so the bare-name fallback detect_provider_for_model
ran and could hand the session to OpenRouter because the bare name exists in
its catalog. Detection now runs only when parse_model_input consumed no prefix.
Diagnosed by AlexFucuson9 in #59294; same fix, expressed as "input unchanged
by parsing" so it needs no provider-name table.
A bare `custom`/`local` session whose base_url is not the trusted config
`model.base_url` re-resolved credentials from config on a same-provider
switch and fell through to the OpenRouter default: the next turn hit
openrouter.ai with an empty (or the custom) key. Keep the session endpoint
and key when resolution comes back empty or on OpenRouter; a config-backed
custom URL still wins so key/endpoint rotation is not pinned to a stale
session.
Diagnosed by fangliquanflq in #74143 / #71693; this is the minimal form of
that fix at the same-provider credential step.
/model <name> on provider A, where the name is only known to provider B
(static catalog or OpenRouter), switched the session to B even when B had
no key: an immediate 401 for most vendors, and for OpenRouter — whose
runtime resolves with an EMPTY key instead of raising — a silent switch
onto a metered aggregator. The dashboard's flat Model field had two more
copies of the same guess ("vendor/model on a native provider" → openrouter).
detect_provider_for_model() now walks its ladder as candidates and skips any
target without credentials (env/.env key, auth-store login, or a usable
credential pool entry). Exceptions: the user NAMED the provider (/model nous)
or there is no current provider yet ("auto") — then the guess is handed back
so the credential step fails loudly instead of silently ignoring input. A
vendor/ prefix naming a provider declared in `providers:` is a selection, not
a guess, and always routes. The dashboard fallbacks apply the same gate.
Tests that pinned "switch to OpenRouter/vendor with no key" now grant the
credential they assumed; two new invariants cover the gate.
The publish-side taxonomy (_end_stamp_class, _compression_parent_obstacle,
compression_parent_deliberately_ended) and the agent-guard delegation produced
exactly main's verdict -- every non-automatic stamp still fails closed -- so
they only changed an error string. Inline the "explicit close with no
continuation" test into reopen_if_explicitly_closed(), restore main's publish
branch and agent guard untouched, and keep two tests: the field shape rotates
after the host clears the stamp; boundary/compression/automatic stamps and a
session already claimed for teardown are never cleared.
Consolidated from PR #106543 (5 commits, final tree d2c4d908) by @Totoro-qaq.
publish_compression_child() fails closed on any non-automatic end stamp and
end_session() is first-stamp-wins, so a stale tui_close on a session the TUI
still routes turned every rotation into "compute the summary, then discard it"
(#106459). The host that still routes the session clears the stale explicit
close via SessionDB.reopen_if_explicitly_closed() before the turn starts;
publication never heals explicit closes. Review probes by @ehz0ah.
- `_target_matches_origin` docstring still claimed fan-out targets are
"deliberately NOT mirrored" and mirroring is origin-only; that has been
false since origin_fallback landed and is doubly so with the home lane.
Point to `_target_mirror_eligible` for the policy instead.
- cron.md DM-only bullet said "origin DM session"; the config comment in
the same change already says "target DM". Align.
- Remove two `_expand_routing_tokens` unit assertions bolted into the
eligibility test; the `" ALL , slack "` parametrize covers expansion
end-to-end through `_resolve_delivery_targets`.
A managed cron with `deliver: "slack"` (no captured origin) delivers to the
Slack home channel, but the brief was never mirrored into that session and
the in_channel seed never fired: bare-platform targets resolved with no
`_resolved_from` provenance, so `_target_mirror_eligible` treated them like
`all` broadcast expansions.
- `_resolve_delivery_targets` derives `from_broadcast` from the raw token
and passes it to `_resolve_single_delivery_target`; a user-written bare
platform token is tagged `_resolved_from: "home"`, `all` expansions stay
untagged (fan-out is never continuable).
- `_target_mirror_eligible`: `home` eligible under the same flags as
`origin_fallback` (per-job attach_to_session wins, else cron.mirror_delivery).
- `_MIRROR_PROVENANCE_RANK`: `home` == `origin_fallback` so token order
through dedup cannot strip eligibility.
- Docs, tool schema text, cron/gateway AGENTS.md updated; existing
exact-dict pins carry the new key.
Squashed from PR #101819 (commits 5fe612c9, 65e36926, 10e0af1d, f2cc4639):
they predate the cron/scheduler.py -> scheduler_delivery.py split and do
not cherry-pick individually onto current main.
The linger hint explains a FAILED systemd unit restart. It ran unconditionally,
so on any Linux login session with linger off and no unit installed the
command printed the hint and exited 0 without stopping or starting anything.
The dashboard/Desktop "Restart gateway" action spawns exactly this command and
polls its exit code, so it reported success while the gateway never came back
with the newly saved credentials. Gate the hint on an installed systemd unit;
the no-unit path falls through to the detached restart as intended.
gateway_state.json preserves per-platform entries across restarts, so a gateway
that once ran without TELEGRAM_BOT_TOKEN and then stopped kept reporting
'fatal / No bot token configured' on the Channels/Messaging pages even after the
user saved a token — the Desktop showed 'Saved' on both fields next to the error.
Only a live gateway's verdict describes current config; when no gateway is running
the platform reads gateway_stopped with no error.
DeepSeek retired deepseek-v4-flash on 2026-09-10 (V4.1-Flash release); the API's
model name is now `deepseek-flash` and /v1/models lists only it. Hermes still
folded every non-V-series name onto deepseek-v4-flash, so `/model deepseek-flash`
on the DeepSeek provider was rewritten, then the validator "auto-corrected" it
back against the live listing: "Auto-corrected deepseek-v4-flash -> deepseek-flash"
on every switch.
Retired aliases (deepseek-chat / -reasoner and other fuzzy names) now fold onto
deepseek-flash; the curated catalog, profile fallback list, aux default, goal-judge
hint and pricing snapshot (2026-09-10 off-peak USD) follow the docs. Dated
deepseek-v4-* ids still pass through untouched.
Builds on YipTszkwan's #107126 (earliest fix in the cluster).
detect_provider_for_model() walked static catalogs and then the OpenRouter
catalog. Providers whose live catalog outruns the static list (Codex
early-access ids, Nous Portal slugs, Ollama Cloud) had nothing to stop the
ladder, so `/model gpt-6-astra` from a Codex session, or `/model glm-5.3-flash`
from a Nous-sub-only session, silently rebuilt the agent on metered OpenRouter
(or on a vendor the user has no key for).
The current provider's disk-cached live catalog (cached_provider_model_ids, 1h
TTL + SWR) is now consulted first: an exact or bare-name hit stays put and
resolves to the provider's own spelling. Fixes the whole class at the shared
choke point, covering /model, ACP, oneshot and the dashboard.
Refs #97487 (sj-unit72 diagnosed the ollama-cloud instance of this).
DeepSeek's 2026-09 Flash refresh introduced a version-less canonical id:
GET /v1/models now returns `deepseek-flash` (alongside `deepseek-v4-pro`), the
API accepts it directly, and the older `deepseek-v4-flash` is server-side
aliased onto it. Every DeepSeek model-id gate in Hermes keys off the
`deepseek-v<N>` prefix, so the new id silently missed all four:
* DeepSeekProfile.build_api_kwargs_extras classified it as non-thinking and
omitted `extra_body.thinking`. The server then defaults to thinking-on, so
the user's thinking toggle and `reasoning_effort` were quietly ignored.
* `_normalize_for_deepseek` folded it onto `deepseek-v4-flash` (it misses the
V-series regex), so the id a user picked never reached the wire and the
config stored a different model than the picker advertised.
* `DEFAULT_CONTEXT_LENGTHS` fell through to the 128K `deepseek` catch-all
instead of the real 1M window, capping the model at an eighth of its
context before compaction kicked in.
* `_REASONING_STALE_TIMEOUT_FLOORS` had no entry, leaving the stale-stream
detector at its 180s default instead of the 600s reasoning-model floor.
Verified live against api.deepseek.com: `deepseek-flash` answers 200 with
`model: deepseek-flash`, accepts image input (the refresh folds vision into
the Flash model), and the in-between id `deepseek-v4.1-flash` is rejected
with "The supported API model names are deepseek-flash, deepseek-v4-pro".
Adds the id to all four gates plus regression coverage for each site.
The Windows hand-off script runs from the PRE-update checkout, spawned from
HERMES_HOME by the Desktop. e7eaff68 shipped the post-update verify step
without the cwd pin that landed a day later, so verify_windows_desktop_update(Path.cwd())
looked for apps/desktop/release under ~/.hermes and reported a healthy,
fully updated install as "The updated Desktop executable is missing" (exit 8,
error dialog, app relaunched fine).
Derive the root from the imported hermes_cli package instead; the ps1 no
longer passes a cwd. The cwd pin stays as belt for the other child steps.
Every GLM slug that missed a DEFAULT_CONTEXT_LENGTHS key fell to the "glm" 202,752 catch-all, so
compression fired at ~20% of the real window and the aux feasibility check auto-lowered the session
threshold to that number (the 202,752 in the Coatue compression report).
- Catalog: GLM entries from Nous + OpenRouter /v1/models (2026-09-09): 5.3 / 5.3-flash 1,310,720
(:batch/:US 1,048,576), 5.2 1,048,576, 5 / 5.1 / 4.7 / 4.6 204,800, *-turbo / 4.7-flash 202,752.
- _catalog_key_matches: version separators normalised on both sides so relay slugs like z-ai-glm-5-3
hit glm-5.3 (#97398; approach from PR #97412 by @shellybotmoyer, re-implemented on current main).
- get_custom_provider_context_length: entry-level custom_providers[].context_length backs every model the
entry serves when no per-model override exists, so /model switch stops dropping it (#98387; from
PR #98396 by @liuhao1024, re-implemented on the config_providers sibling).
- check_compression_model_feasibility: when aux compression is the main model on the main route, reuse the
main model's resolved window instead of re-resolving without its pin (#89500 mechanism 2, #45519).
Closes#97398, #98387, #89500, #87825, #97595, #97820 (suffix strip already on main; catalog values now match).
The MCP-catalog noninteractive contract test asserts every subprocess spawned during a git
install carries GIT_TERMINAL_PROMPT=0 and a closed stdin; the gh token probe was spawned with
the inherited env. Use the same hardened env (plus GH_PROMPT_DISABLED) so gh cannot open a
browser/device flow either, and let the contract accept a stdin fed by input= (credential fill
writes its request and closes).