The spotify toolset (plugins/spotify) and its loopback-PKCE login (hermes_cli/auth_spotify.py)
now live in NousResearch/hermes-spotify, installed from the plugin catalog. Same pattern as the
Home Assistant move (fb9fc8a3dd): a LEFT_CORE row installs the plugin for every home that was
using it (a Spotify login in auth.json, or the spotify toolset in platform_toolsets) on
`hermes update` / first start; the toolset name, tool names, schemas, env vars and stored login
(providers.spotify) are unchanged, so nobody re-authenticates.
The one user-visible change is the login command: plugins cannot extend `hermes auth`, so the
plugin registers `hermes spotify login|status|logout`. LeftCoreFeature gains a `cli` field and
moved_command_hint() turns `hermes auth [status|logout] spotify`, and `hermes spotify` while the
plugin is not loaded, into a line naming the new command and the install/enable step for this
home. The dashboard's "needs login" hint names the new command too.
Core drops the Spotify auth constants/dispatch, the CONFIGURABLE_TOOLSETS row and post-setup
step, the core `spotify` entries in the shared-metrics toolset enums, its tests, and the docs
(en + zh-Hans) now point at `hermes plugins install spotify` / `hermes spotify login`.
The Desktop/TUI/dashboard toggle edited the saved list before the plugin
loaded, so a backend that scanned while the plugin was off found no toolset
key and left the toolset out: the plugin showed enabled with its tools off.
Enable now edits the list after activation; disable resolves the key before
the disable is written. hermes plugins enable/disable make the same edit.
Only saved lists are touched; a fresh config still gets no list.
- hermes_cli: _find_plugin_entry is the single resolver (key / manifest name / unique leaf /
catalog name via the installer-owned record). enable/disable/show/capabilities/update/remove,
the dashboard and plugins.manage settings all go through it, so the catalog name you
installed with keeps working (17 catalog entries install under another manifest name).
_catalog_install_on_disk reuses the same catalog lookup.
- Desktop hub: join agent rows to desktop halves by package folder (what Electron stamps),
not manifest name, so a self-cloned package is one row instead of two.
- Desktop: turning a unified package on (install dialog 'Enable after install' or the Agent
switch) also turns its opt-in desktop half on, unless the user switched it off. Off stays
per half. Capability consent is untouched.
- docs: plugin-catalog page.
A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:
- CLI /new: printed before the new session; CLI quit: printed above the
exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
adapters are still connected
on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
Replaces the six TestUnattendedPlatformExecAskLeak cases in test_approval.py (past its
size cap) with two invariants in a sibling file, both red on main:
- webhook / msgraph_webhook deny from approvals.unattended_mode even with the
TurnRunner-registered notifier, and never reach the human gate;
- api_server denies without a notifier and keeps the ask path once a client bridge
(/v1/runs, streaming chat) registers one.
security.md: unattended_mode now names the api_server exception.
Root cause first identified in #100554 (@fangliquanflq); webhook leg from #134821.
/yolo on a messaging platform only flipped tools.approval's in-memory
set, so any gateway restart (hermes update, crash, systemd restart,
Desktop reopen) silently put the chat back on manual approvals while
the user still believed YOLO was on.
The flag now lives on the chat's routing entry (SessionEntry.yolo,
persisted in gateway_routing like the /model override). /yolo writes it
before flipping the live flag, materializing the entry for a
first-message toggle; every turn re-arms the live flag from the entry it
already resolved; switch_session carries it across compression-tip and
binding repoints; the conversation-boundary security funnel (/new,
/resume, /branch, auto-reset) clears the persisted copy with the
in-memory one so a restart can never revive a bypass the user dropped.
Also corrects the security docs, which still claimed /yolo sets
HERMES_YOLO_MODE.
- Decide the profile claim when the row is written (the check from #101827)
and drop the None sentinel from #103712. On current main load_pool() leaves
a profile that borrows from an empty root at the default, so the sentinel
alone never reached the claim path (its test stays red).
- add_entry() re-reads the store after writing. A missing row raises
CredentialNotSavedError; `hermes auth add` exits with that message instead
of printing "Added", and the dashboard pool endpoint returns it as a 400.
- Tests: the two contributor tests become one invariant over every
SINGLE_USE_REFRESH_POOL_PROVIDERS provider (the row is readable, root
auth.json is byte-identical), plus the CLI success-line contract.
- Docs: profiles page, EN and zh-Hans.
Fixes#103694
(cherry picked from commit 0e250a005a3353564642ce70f100d89174a583f2)
The #115692 reclaim released any running attempt whose claim was older than
max(3 x HERMES_CRON_TIMEOUT, script timeout, 2 h), even when the owner was
healthy and busy. Every cron job that legitimately ran longer than that was
marked `unknown` mid-run, its fire claim yanked from under it and the run
aborted with "lost its durable fire claim ownership" (two weekly jobs on one
install in a single day).
The run monitor now stamps `progress_at` on the execution row every 60 s
while the agent reports recent activity, and the sweep measures the stale
bound from that stamp (claim age for legacy rows). A wedged worker stops
stamping and is still reclaimed once the bound passes.
Shape: the stamper and the inactivity watchdog loop (which reads the same
idle signal) live in a new topical sibling cron/scheduler_liveness.py, so
cron/scheduler.py shrinks (4457 -> 4436 lines) instead of growing.
The installation page said "Light is a remote-only build variant" next to
the download instructions, which read as if a remote-only Desktop client
can be downloaded. Light exists only as a build target with no release
leg, and there are no plans to publish it. Replace the sentence with how
remote use actually works today (install a package, connect from Settings
-> Gateways), drop "Light packages" from the uninstall note, and mark the
variant as unpublished in the desktop README.
The self-improvement review fork refused every edit/patch/write_file/remove_file on a skill
without `created_by: "agent"`, so lessons about hand-written, URL-installed, foreground-created,
bundled, hub, external or pinned skills were dropped with only a WARNING line (77% of refused
review writes on the #134289 reporter's install). The prompts then steered the fork to a
different umbrella or a new near-duplicate skill.
Ownership now gates only delete/archive: the review may improve any skill it learns from (still
read-before-write, ledgered, and staged when skills.write_approval is on), while autonomous
archival stays limited to curator-managed, unpinned, non-bundled/hub/external skills. The review
prompts drop the protected-skill lists and say never to delete.
Fixes#134289
MemoryManager forwarded turns, transcripts and tool output to external
memory providers without the egress scrub, so a key echoed into tool
output was archived verbatim in the provider's store.
Every fan-out now goes through redact_for_egress (the chat/cron delivery
scrub; holds with security.redact_secrets off, fails closed): sync_all
(turn strings + messages), prefetch_all / queue_prefetch_all,
on_turn_start, on_session_end, on_pre_compress (both transcripts),
on_memory_write (content + metadata), on_delegation and provider
handle_tool_call args. Nested dicts/lists (tool-call arguments,
multimodal parts) are walked; data: URLs pass; the live transcript is
never mutated. A value masked anywhere in one hand-off is masked
everywhere in it, so a key the model repeats bare in prose is caught.
redact.py: the JSON-field rule now also matches inside JSON-encoded
strings (tool results wrap config dumps in escaped JSON), which is how
the reported opaque AQ.-prefixed key slipped past.
Co-authored-by: Teknium <teknium1@gmail.com>
auxiliary.voice_chat.reasoning_effort ships as "none", and it applies even when the slot is on
auto (the session's model answers the spoken turn with reasoning off; typed turns keep their
effort). Routes that cannot switch reasoning off get their weakest accepted level instead:
Responses ladders without "none" (gpt-6-astra, gpt-6.1-sol -> low), mandatory-thinking Claude,
and routes the catalog or an earlier 400 marked mandatory. A disable the voice model rejects at
runtime is remembered per route for the next voice turn, and the rejection flags never leak
between the voice route and the main one.
"Inherit" in the dashboard/Desktop writes an explicit "" so the slot default does not come back;
both resets restore the shipped default.
Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (#83894). A new auxiliary slot, voice_chat, picks the model for voice turns:
- CLI/TUI voice mode, the Desktop voice conversation (prompt.submit voice_turn) and messaging
voice notes mark the turn; agent/voice_turn_route.py binds the route after the prompt, row and
preflight compaction were settled against the main model and undoes it before memory sync and
background review, so the next typed turn and the review run on the main model.
- A turn larger than the voice model's compression threshold stays on the main model with a
one-time notice: switching to voice never compacts the conversation.
- Usage lands in session_model_usage under task voice_chat; it never rewrites the session's
recorded model/route, so resume keeps the model the user picked.
- Slot appears in Desktop Settings -> Models, the dashboard, and `hermes model`; GPT-Live
delegations keep using the session model.
The desktop guide said nothing else on the computer is read for setup, but building the setup cards checks whether Blender and the NVIDIA apps are installed, reads their versions and for some apps their runtime file. Greptile on #134209.
OpenViking now ships from Volcengine's own repo through the plugin catalog
(plugin-catalog/openviking.yaml, pinned in #131267). Delete the in-tree copy
and its tests; memory_provider_migration already lists openviking in
_LEFT_CORE, so homes configured for it get the catalog plugin installed on
hermes update / first agent start.
The catalog pin starts openviking-server with the full Hermes environment
(the bundled copy filtered it). The docs now say so and point at
volcengine/OpenViking#5553; start openviking-server yourself to avoid it.
The desktop's old recommendation inputs were the only client of
GET /api/mcp/catalog?detect_apps=true and the only sender of the guide_*
onboarding steps; both went with the old ::onboarding chain.
- The catalog route loses detect_apps; mcp_app_detection.py and its two
test files go. The manifest's suggest.applications validator moves into
mcp_catalog.py, which still validates applications / requires_app /
examples (documented in features/mcp.md).
- DESKTOP_ONBOARDING_STEPS and the v4 schema drop guide_connectors,
guide_first_build, guide_layout and guide_look. v3 stays as published.
- developer-guide/onboarding-recommendations.md described that pipeline
and is removed.
The capture route only checked "does the provider carry tool-result
media", never `agent.image_input_mode` or a catalog verdict that the
model cannot see. deepseek-v3.2 on OpenRouter therefore went native, the
agent backstop swapped the screenshot for a "switch to a vision-capable
model" error, and the configured auxiliary vision describer never ran;
`image_input_mode: text` was ignored for screenshots too.
`tools.vision_tools._native_tool_result_images(provider, model, cfg)` is
now the one gate for every tool that hands the model pixels
(vision_analyze, browser screenshots, computer_use captures, MCP images):
image routing resolves to native AND the route accepts tool-result
images. The capture route is its negation, which drops three local
helpers that duplicated image_routing. Proxy aliases the catalog does
not know still go native under `image_input_mode: native` (#115248's
case); `auto` sends them to aux, matching vision_analyze.
Tests: test_computer_use_vision_routing.py is rewritten to drive the
real gate (the old cases patched the removed helpers). Changed
assertions: "whitelisted provider + catalog-unknown model" now requires
image_input_mode: native (it carried that cfg already); new cases pin
catalog text-only -> aux and image_input_mode: text -> aux.
An MCP tool's ImageContent was cached and the model got back only a
`MEDIA:/path` line, so a vision main model could never read the image
(a raw cua-driver MCP server could not see the screen at all).
The registry handler now returns the `_multimodal` envelope when the
active route takes images inside tool results, decided by the same gate
vision_analyze and browser screenshots use (`agent.image_input_mode`, an
explicit `auxiliary.vision` backend, catalog vision, provider support).
The image is re-encoded from the sniffed cache file, normalized to a
provider-accepted format and sized to `vision.embed_target_bytes` /
1568 px (it is re-sent every later turn), at most 4 per result; the text
half keeps the MEDIA: paths. Paths come from this call's own cache
writes, never parsed out of server text. The resize runs on the tool
thread, off the shared MCP loop. Plugins' `ctx.call_mcp` keeps its
string contract (native_images is set only on the registry handler).
Builds on #131374 by @sarahwu185, which reused the computer_use capture
gate (ignores image_input_mode, native for catalog text-only models) and
inlined the server's raw base64 unvalidated and unresized.
faster-whisper accepts `turbo` and the config docs listed it, but the CLI
picker, dashboard and Desktop settings stopped at large-v3, so the only way to
use it was hand-editing config.yaml. All three now read it from the shared
STT_MODEL_CATALOG, along with the install hint and the cloud-model warning.
Salvage of #79671 by @tank321, rebased onto the unified catalog from #133825.
Gate r4 (2c + quality, both probed): the r3 hysteresis dropped an "unwritable"
transition that came within an hour of the last notice. note_unwritable reports
entry only once, so for unwritable -> recovered -> unwritable the home channel's
last notice said "writable again" while the store stayed down, possibly for days.
Rate-limit the recovery instead. "unwritable" is sent only for a store whose
outage was not announced yet; "recovered" waits NOTICE_REPEAT_SECONDS on the
gateway loop and is sent only if the store is still writable then; a re-entry
meanwhile cancels the pending recovery and sends nothing. The last notice always
matches the store's real state, and a flapping disk posts at most one pair per
window. All bookkeeping now runs on the loop (call_soon_threadsafe), so the
ticker thread no longer needs a lock, and the notice coroutine is created
there too: a transition after the loop closed raises before any coroutine
exists (logged by store_health._notify), instead of leaking an un-awaited one
(gate r4 reuse item, loop-side variant).
Gate finding C12 r3 #5 (2c): the degraded state lives in gateway memory, so a
one-shot that came due during an outage only fires on recovery if the
gateway did not restart in between. After a restart the normal grace window
applies and it can expire. Say so in the recovery sentence rather than
seeding "since" from jobs.json's mtime, which would revive one-shots that
were really missed.
The troubleshooting section beside `hermes cron status` now covers what users see when the cron
store can't be written: the status headline, the `cron list` banner, the doctor check, the
home-channel enter and recover notices, and the metrics. It also gives the fix (free disk space,
remount read-write, fix permissions) and says that each due job fires once on recovery.
Main sends the first-contact note (the profile-build offer or the plain
intro) from the messaging gateway and the TUI/desktop backend only. This
branch added a third caller in the CLI chat turn, so the first `hermes chat`
message in a fresh home gained the profile-build offer and wrote
onboarding.seen.profile_build_offered to config.yaml. That includes one-shot
`chat -q` runs and kanban workers on new profiles.
The CLI caller goes; /initiate-setup stays a CLI command. The configuration
reference no longer lists the CLI among the surfaces that make the offer.
test_cli_first_message runs a real `hermes chat -q` in a fresh home against
the fake provider: it fails on 70f1ba1637 and passes on main and with this
change.
The owner watchdog (#132219) retires a detached serve --isolated backend once
backend.lock.json names a newer spawn, but it only trusts a lock whose logPath
ends in /<ownershipId>/<nonce>.log. Locks on native Windows SSH hosts are
written by windows_ssh_runtime.write_lock from the Desktop's record, which
carries no logPath, so read_valid_backend_lock returned None there and a
reconnect storm kept stacking backends.
write_lock now stamps the runtime's own log path for the lock's nonce, in
forward-slash form so the suffix check matches. All three Windows lock writes
(the in-mutex spawn record and the two Desktop updates) pass through it, and
the fix ships with the host install, so no Desktop rebuild is needed.
Refs #132034
Slack caps an app at 50 slash commands and main fills all 50. 9c4eecb53d
made room for /initiate-setup by demoting /commands to /hermes commands.
Setup is the rarer command on Slack (the first-message pointer to it only
ships in the Store desktop app), so it takes the /hermes-only slot instead
and /commands is native again.
The first-contact note spells the command per platform from one table:
/initiate_setup on Telegram, /hermes initiate-setup on Slack.
Docs: the onboarding config section and the /initiate-setup row describe
the profile offer as the default and the setup pointer as the Store app's.
On a fresh Windows profile `winget install` stops at the source and package
agreement prompts, which the agent's terminal cannot answer; the install line now
passes both flags and pins --source winget. On Arm the task reports the
architecture winget actually picked instead of inferring it. The "Install a few
apps" option names the machine as setup does (Spark, PC, Mac) instead of saying
Spark on every NVIDIA PC. Skill page regenerated.
The machine-setup task is offered on every OS, and the Spark row only ever reaches Windows and Linux, but the
skill only had Homebrew steps. It now names winget and flatpak beside brew. The desktop guide said nothing is
stored; setup keeps a setup-cards file with the machine line and the picks.
The name was in the /initiate-setup facts block and in the scripted name card's saved arguments, so it left the machine even when the user typed another name or skipped the card. facts() drops it, and setup_choose adds the suggested row itself after the model's call is saved, the way it fills the tour and fork rows. The result's label carries the name when the user picks it.
Brings the onboarding branch up to main 0c2ff854a5. Where a Wave A PR
already landed a reviewed version of a piece of this branch (free tier
#134049, keyless web #134048, app resolver #134050, bundle and uninstall
#134051, catalog card and toolsets #134052, built-in tour #134054, stdin
guard #134055, retired key #126146), main's version wins. The PM long-path
files also take main's version: that work lands through #130430.
Kept from the branch on top of main:
- the setup and start_chat toolsets, declared with main's platforms key
(desktop only), and the setup_choose server request contract;
- the setup-profile marker file (.setup-profile.json) instead of the
profile.yaml role, as c3f85bc54f decided;
- the first-contact note's message argument beside main's internal flag;
- the setup-profile gate on the shared-metrics consent dialog;
- window sizing (window-growth) in place of chat-onboarding-window.
Desktop i18n follows main's per-section sibling files. The onboarding
strings go into them (modelMenu.localSetup, handoffTour.localTitle and
localText) and setupChoose/startChat stay in en.ts. guidedGreeting is
gone. The shared-metrics copy uses the branch's rewrite in English, with
the pre-opt-in sentence corrected for the fresh-install note
(d3674513c8); locales keep main's translations, with installId and
consentWindow folded into the new sending key.
The built-in tour reads local-model eligibility from the local-setup
offer store, the one place that rule lives on this branch. The gateway
contract and the onboarding skill docs pages are regenerated.
The setup chat no longer scans the computer. The collector, its host_facts.py
hook, the inline-shell setting on the setup profile, the ${HERMES_PYTHON}
skill token and the insights/ cache are gone.
The first /initiate-setup turn now sends the skill plus one JSON block that the
model reads as it is: machine basics (OS, release, architecture, CPU, RAM, GPU
class, vendor, WSL, container), machine_kind, has_nvidia_gpu, is_spark,
suggested_name and locale, beside the session keys. A key that was not
measured is left out.
The non-scan parts of host_facts.py (account name, locale, Spark detection,
the fork card, the NVIDIA and Blender tasks, the handoff text) move to
agent/initiate_setup_facts.py and are imported normally instead of loaded by
file path.
tirith ran `tirith check` on every terminal command, default-on with fail-open, as a
pinned PM binary auto-downloaded at startup. Measured against the shipped 0.4.2 binary
it caught 14/16 attack commands but flagged 12/24 everyday agent commands
(`echo $((6*7))`, `"$VAR/x.sh"`, `gh api | python3`, LAN curls, `.dev` hosts,
`pip install` via analysis_incomplete), and in cron/-q deny mode each of those is a
hard block. It only ever saw terminal strings (execute_code, writes and the browser
bypass it), fail-open meant no protection wherever the binary could not install
(Windows, Termux, x64 musl), and it produced a steady issue/PR stream (fail_open
semantics four times, install loops filling disks, a SIGSEGV hang, session-approval
scoping, third-party promo text on approval cards).
Removed: tools/tirith_security.py, the tirith branches of the command guard (the
is_tirith tuple, session-only "always" downgrade, import-error/fail-open paths,
unattended tirith pass), the CLI and gateway startup installers, the PM package and
lock pins, the nix runtime dep, security.tirith_* defaults, the CLI warning string
and three tirith tips (gaps refilled from the tail so translations survive), plus
the gateway's "no automated assessor" warning that only existed to point at tirith.
Config v50 drops security.tirith_* and enables nothing in its place. Hermes ships
no tirith plugin; if tirith upstream publishes one, it goes through normal catalog
admission under their name.
Voice input was record-then-upload on every surface: nothing showed while the user
talked, and the transcript arrived only after the whole take uploaded. With
`stt.streaming: true` the openai, xai and elevenlabs providers now stream microphone
PCM to their realtime endpoints while the user speaks. Partial text renders live and
the final transcript is ready at end of speech.
- tools/transcription_streaming.py: one session API (push_audio / end_audio /
finalize / cancel, an on_partial callback, a worker thread that owns the socket)
with three wires, all verified live: the OpenAI Realtime `type: transcription`
session (gpt-live-transcribe, 16->24 kHz resample since the endpoint refuses
< 24 kHz), xAI wss /v1/stt with interim results, and ElevenLabs Scribe v2
realtime. Plugin providers opt in through
TranscriptionProvider.streaming_capable / open_stream_session.
- tools/voice_mode.py: AudioRecorder taps its capture callback into the session and
parks it under the WAV path. transcribe_recording uses the live transcript and
falls back to the file on any failure or empty result, so CLI push-to-talk, the
continuous VAD loop and the TUI gateway gain streaming with no call-site changes.
- Classic CLI and Ink TUI show partial text in the input placeholder while recording
(new `voice.partial` gateway event, contract regenerated). Desktop dictation
streams PCM to the new /api/audio/transcribe-stream socket (provider keys stay on
the host, and the host resamples) and shows partial text in the dictation pill.
The recorded blob stays the fallback.
The plugin streaming contract (streaming_capable / open_stream_session /
TranscriptionStreamSession) and the transcribe-stream socket shape come from #103173.
Co-authored-by: Vinicius <timelord1025@gmail.com>
* fix(agent): free-tier rate limits end an attended turn at once
A free-tier (no sign-in) session that hit the service-wide rate limit slept
up to 600 s, retried into the same refusal, slept again, and then sent its
third attempt on a guest JWT that had expired during the wait. The welcome
host answered 403 "invalid jwt", which read as the tier being switched off.
The user watched a spinner for about 18 minutes and then saw the wrong card.
- An attended session (desktop, TUI, the interactive CLI, ACP) on the free
model ends the attempt cycle at once when the cooldown is longer than
60 s: fallback first, else the reset time and the sign-in / model doors.
"Attended" is keyed on the session source, so `hermes chat -q`, cron and
messaging sessions keep the long wait. The source vocabulary moves from
run_agent.py into agent/session_source.py, which adds is_attended().
- on_free_model() (welcome host + anonymous credential) is the one free-tier
check, used by the cutoff, the "free model is busy" wait copy and the
outage copy.
- A welcome-host 403 "invalid jwt" is its own route kind, session_expired:
the existing one-shot route heal re-exchanges the credential and retries;
if that fails the card is the retryable outage card.
- interruptible_backoff_sleep adopts a fresh Nous key after each wait.
_adopt_nous_key_before_expiry owns its refresh failure, so both call
sites drop their try/except.
- A retry wait over 60 s is announced in the transcript when it starts, for
every provider; shorter waits stay buffered. Every wait line names the
attempt that runs after it.
- max_retries_exhausted_result's max_retries becomes attempts (what it
carries); a one-attempt rate-limit end gets its own lead.
- One Retry-After reader, retry_utils.provider_retry_after_seconds, shared
by compute_error_backoff and the recovery ladder.
- _session_default_route replaces _session_default_model everywhere in
tui_gateway, so a not-yet-built free-tier session reports nous/welcome on
create, reuse, resume, branch and row persist, not the silent default.
* fix(agent): free-tier cutoff no longer ends subagent or library turns, paid waits look like main (review)
A delegated child of a TUI/Desktop session and a library caller (no platform) are unattended, so they keep waiting.
Rate-limit wait lines keep main's attempt numbers and stay buffered for every provider; only a non-rate-limit Retry-After over 60 s is announced, as on main.
The unattended free-tier wait line keeps the reset time beside "The free model is busy".
The free-tier cutoff names the reset time for any cooldown it fires on (over 60 s), not only from 120 s.
session.info for an env-seeded model off the free tier carries the model alone again, as on main.
Behaviour tests for the cutoff, the subagent and unattended waits, and a paid long wait.
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
* fix(web): keyless search fails over when a free tier refuses the client
Firecrawl's anonymous endpoint answers 403 (an IP reputation gate) for some
clients, and a free tier can also answer 402 (credits gone) or a 5xx. Main
already moves a keyless search to the next ring vendor on a structured
401/403 (2f78707a79, from #91619), but two cases still stopped the walk:
- Keenable's keyless client raised only the response body, without the
status code, so its 401/403 never matched the structured status check.
- The keyless Firecrawl client raised httpx's default "Server error '503 ...'"
text for 5xx, and no status pattern covered 402 or 5xx.
The status pattern now covers 401/402/403 and 5xx, and both keyless clients
raise "HTTP <code>: <vendor reason>", so the walk sees the status and the
error keeps the vendor's reason. Extract keeps main's rule: it fails over
only when every URL is rate-limited, because a 403 there can come from the
page rather than the vendor.
* fix(web): a search error that echoes the query no longer walks the keyless ring (review)
The vendor-refusal status match now judges only the status the error starts with, so 'HTTP 400: invalid query "http 503"' stops the walk instead of trying every free vendor.
Cloud STT refused anything over a flat 25 MB with "File too large", and only CLI
voice mode had a WAV-only chunker. Gateway voice notes, audio attachments and
Desktop/TUI relays just failed. xAI (500 MB), Mistral (500 MB) and ElevenLabs
(5 GB) were capped at 25 MB too.
transcribe_audio now owns this for every surface. tools/transcription_chunking.py
holds the vendor-documented per-provider byte caps plus OpenAI per-model duration
caps that were measured live. gpt-4o-transcribe truncates at its 2,000-token output
ceiling, so 1322 s of speech came back 203/260 complete, and it rejects more than
1400 s. whisper-1 at 2671 s outran the 60 s request timeout. An oversized input is
re-encoded to the compact STT m4a first, which needs no seams. When it is still too
large, it is cut at silences under the cap and the transcripts are joined in order.
Hints (stt.prompt, pre_transcription hooks) resolve once per recording, not once per
segment. The voice-mode-only WAV chunker is gone.
POST /v1/stt still defaults to grok-voice-transcribe-1.0 when model is
omitted. Hermes logged "grok-stt" and never sent the field, so voice notes
stayed on 1.0. Send stt.xai.model (default 2.0, overridable via STT_XAI_MODEL)
on the gateway and the desktop client-direct path.
(cherry picked from commit c869631946e9a9d84b1738bfdd278f926300da00)
Both dashboard-auth JWT verification paths (self-hosted OIDC ID token and the
Nous Portal access token, via the shared verify_jwt) called PyJWT jwt.decode
with the default leeway=0 while requiring iat. Any host whose clock lags the
issuer's — unsynced containers/VMs, NTP jitter — failed login with
ImmatureSignatureError, and the failure was folded into ProviderError, i.e.
HTTP 503 'provider unreachable', discarding already-rotated refresh tokens
and forcing unrecoverable re-logins.
- verify_jwt gains leeway (default 60s, RFC 7519 §4.1.4-4.1.6) and classifies
ImmatureSignatureError as InvalidCodeError (validation failure -> 401 /
refresh) instead of ProviderError/503.
- New parse_leeway helper: fail-closed parsing (unparseable / negative / NaN /
inf -> 0, never widens the window); 0 honours strict mode.
- Self-hosted OIDC: id_token_leeway kwarg + dashboard.oauth.self_hosted.id_token_leeway.
- Nous: token_leeway kwarg + dashboard.oauth.token_leeway.
- No new env vars (config.yaml-only knobs).
Fixes#47815
plugin-catalog/mem0.yaml (#131267) points at mem0ai/mem0's own
integrations/hermes-plugin-mem0 at a reviewed pin. The entry stays dormant
while a bundled copy exists (bundled providers win in find_provider_dir), so
deleting the in-tree copy is what lets hermes_cli/memory_provider_migration.py
install it for every home whose memory.provider is "mem0". The vendor copy
keeps the provider name, mem0.json, the MEM0_* keys and the setup wizard.
Also dropped: the hermes-agent[mem0] extra, its win-arm64 platform row and
exclude-newer row, the pm/extras.py anchor (uv lock removes only mem0ai,
posthog, qdrant-client and sqlalchemy), the mem0-specific doctor check (the
generic check now names `hermes plugins install mem0`), and the tests that
imported plugins.memory.mem0. Prose that lists the bundled set now names mem0
as a catalog provider. MEM0_* env hints and redaction stay: the catalog copy
still reads them.
Desktop's web picker had no way to send one capability through the Nous
Tool Gateway while the other used your own key. "Use for Search/Extract"
on the Nous Subscription row wrote the row's vendor string (firecrawl)
into web.<cap>_backend, which the runtime reads as BYOK Firecrawl: the
gateway was never used, and the managed and BYOK rows lit up together.
- The managed per-capability pick now stores `nous`; search/extract
dispatch, the registry and the Firecrawl client resolve the route per
capability (search: managed Perplexity, extract: managed Firecrawl).
- A whole-toolset Nous pick clears stale per-capability pins (they
outranked the new selection).
- /api/tools/toolsets/web/config reports search_via_nous /
extract_via_nous and each row's managed_nous_feature; the panel lights
the right row, labels the route, and refetches after a pick instead of
mirroring the vendor string.
Fixes#100513. Builds on the approach in #100539 by @liuhao1024 and the
self-healing pin clear from #122676 by @praggybuilds.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Murphy (praggybuilds) <praggybuilds@users.noreply.github.com>