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 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.
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>
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>
Independent-review findings on the plugin PCM streaming path:
- stream_sample_rate was checked, then re-read and int()-ed in the
adapter: NaN/inf raised out of resolve_streaming_provider (the speaker
then played nothing) and 0.5 became sample_rate=0. Validate once
(finite, >= 1 Hz) and hand that int to _PluginPCMStreamer.
- `nous` is an alias of the openai path in _get_provider but not a
reserved name, so a pinned `tts.streaming.provider: nous` could stream
a plugin registered as `nous` while the sync path speaks OpenAI.
- Document that stream() must be thread-safe: the speaker prefetches up
to 3 sentences, unlike the serialized synthesize() path.
Follow-up to the salvaged plugin-streaming commit. The adapter read
voice/model from the plugin's own tts.<name> section, so a streamed reply
spoke the plugin default while synthesize() got tts.voice: the voice
changed with the surface. It also skipped the sync dispatcher's routing:
a same-named `type: command` provider (which wins for synthesize) lost
to the plugin on the streaming path, and an is_available() == False
plugin was streamed anyway.
- tools/tts_tool_plugins: one routing guard (_plugin_route_key) and one
voice/model/speed builder (_plugin_voice_kwargs), shared by
synthesize() dispatch and the new _plugin_pcm_streaming_provider
(streams_pcm + positive stream_sample_rate + is_available(), read at
resolve time)
- tools/tts_streaming: _PluginPCMStreamer stays a thin adapter;
stream_channels/stream_sample_width dropped (the contract is int16
mono, which every sink assumes)
- agent/tts_provider: streams_pcm / stream_sample_rate declared on the
ABC with the contract
- tests: two invariant tests next to the plugin dispatch tests (the
streaming test module skips without numpy); docs for plugin authors
Third-party drivers (e.g. a Codex-style computer-use reimplementation) could
only replace cua-driver by patching core. computer_use now follows the
single-select provider model Hermes uses for memory providers, context
engines and cron schedulers:
- plugins/computer_use/__init__.py discovers bundled
plugins/computer_use/<name>/ then user $HERMES_HOME/plugins/<name>/
(marker: register_computer_use_provider / ComputerUseProvider) and loads
ONLY the provider computer_use.backend names (default "cua"), read per
call so each profile gets its own. Unselected providers are never
imported; listing reads plugin.yaml only. An unknown/unloadable name
returns an error naming it and the installed providers; no fallback.
- The built-in cua-driver is an ordinary bundled provider at
plugins/computer_use/cua/ (driver implementation stays in
tools/computer_use). tools/computer_use/backend.py gains
ComputerUseProvider (create_backend(permission_mode=...), is_available(),
optional doctor()).
- The tool's check_fn and backend construction go through the selected
provider; the model-facing schema is unchanged for every provider
(prompt-cache invariant). The test-only HERMES_COMPUTER_USE_BACKEND env
switch is gone (tests patch _new_backend).
- General plugin discovery treats computer-use providers as exclusive
(not eagerly imported, not listed as toggleable) and skips the bundled
computer_use/ category, same as memory/, context_engine/, cron_providers/.
- hermes tools and the Desktop toolset panel list installed providers next
to cua; computer-use doctor/status/permissions and the Desktop status
payload skip cua-driver/TCC probes when another provider is selected.
`ctx.inject_message` could only reach an existing gateway session (`session_key`), so an
orchestrator plugin that opens a chat/thread itself (a bot-authored Discord forum post never
comes back as an inbound message) had no way to get an agent turn there.
`origin` takes the SessionSource.to_dict() shape. The gateway creates (or continues) that chat's
session through the adapter's normal message path, in the profile whose plugin manager loaded the
plugin, and the reply goes back to the origin chat:
- Profile: resolved from the manager's immutable home, never an ambient one. The origin may not
name another profile, the gateway's own routing for that chat through that profile's adapter
must land in the plugin's profile, and the profile needs its own connected adapter for the
platform (another profile's bot is never borrowed). Refusals are synchronous False + one log line.
- Authorization: same current-allowlist check as the session_key path (no bypass for host code).
- Multiplex: handle_message runs inside the profile's runtime scope so the spawned turn AND the
reply send after it inherit that profile's home and secrets (applies to the session_key path too,
which used to send from the default loop context).
- allow_gateway_injection is read from the plugin's own profile config.
- Plugin/system turns (event.internal) skip the human first-contact notes (onboarding intro and
the "no home channel" nudge), which were posted into the plugin's fresh thread.
Requested by canonicle-desk in #132609.
Closes#132609
The receiver buffers PCM per speaker with no conversation identity, and a same-voice-channel /voice join keeps that receiver. The poll and the leave flush then read the binding after draining, so audio captured for channel A was stamped with B and passed the post-STT check. The gateway join handler now discards the receiver's unemitted audio, under its lock, before moving the binding to a different text channel; a first join or same-channel rejoin keeps it. Nothing awaits between the discard and the binding write.
Reported by andrexibiza (F1) on #133471 and #130312.
The listen loop transcribes a check_silence() batch serially, so a later
utterance read its binding after a /voice join landed during an earlier
utterance's STT and was posted to the new channel. Capture the binding
once per batch (and per leave flush) and pass it in.
(cherry picked from commit e4b020a0fd21833a89fc1fa7f904db489d797d9c)
_process_voice_input awaits WAV conversion and STT, then calls the
gateway callback with only guild, user and transcript, and the callback
routes by the guild's binding as it is at that moment. A `/voice join`
from another text channel while the bot is already connected rewrites
_voice_text_channels and _voice_sources, so an utterance captured for
the old conversation became a turn in the new one, with its channel
prompt, bound skills, transcript echo and replies.
The bound text channel is captured when the utterance is accepted and
compared after STT; if the binding moved, the stale work is dropped and
logged. Rejoining from the same channel keeps the conversation and is
dispatched as before. Nothing awaits between that check and the
callback's own read of the binding.
Reported by andrexibiza (F6) on #128788.
(cherry picked from commit 47d515eb4e1633945212e42d6cf77aba41906ea8)
Two install routes refused a catalog memory provider that config already
names (memory.provider), so the install could never succeed and was
retried on every agent start / every click — the hindsight (91%) and
mnemosyne (87%) failed rows in hermes.extension.install.count.
1. Agent init (`recover_at_startup`) installs the configured provider
through the consent gate. Under the classic CLI that gate's input()
runs on the agent-build thread behind prompt_toolkit, so the turn
hangs on "Initializing agent..." forever; with no terminal (Desktop,
gateway, `chat -q`) it is refused — "Install declined: dependency
install skipped (non-interactive)" — and recorded failed on every
process start. Agent init now never asks: a provider that shipped in
core installs with its built-in's consent (security.allow_lazy_installs
still gates it), any other catalog provider is not attempted and the
user is told the one command that installs it.
2. The dashboard/Desktop Install click (`dashboard_install_plugin`) on a
plugin memory.provider already selects hits the publication veto that
is meant for replacing an active plugin, so it is refused every time
while the same plugin unselected installs and enables. The click now
carries the consent the fresh-install enable already acts on; a forced
reinstall keeps the veto.
Catalog category "memory" also holds hook plugins, context engines and
skill packages. Deciding from the category made `--enable` overwrite the
user's memory.provider with an unloadable name and never enable the plugin.
Decide from the installed tree (_is_memory_provider_dir), and save
memory.provider only when find_provider_dir resolves the name.
The install prompt for a memory provider now sets memory.provider; say so on
the Hindsight setup and plugin install pages and drop the claim that the
plugin is enabled under plugins.enabled.
Fleet telemetry (14d, v0.21.5) shows the builtin store returning
success=False on 34-47% of add/replace/remove calls. Live `hermes chat -q`
runs against origin/main reproduced the dominant causes:
- Drift guard refused every replace/remove/batch on a file whose only
difference from the tool's own layout was whitespace (blank lines
around the delimiter, an empty entry). Parsing only strips whitespace,
so flushing such a file loses nothing; the round-trip signal could not
protect any content and turned the store read-only until hand-fixed
(#107270). The guard now fires only on the signal that can actually
lose foreign text: one entry larger than the whole store's limit.
- An over-budget add/replace/batch said "would exceed the limit" but not
by how much, so models retried with consolidations that were still too
big. Every over-budget message now states the exact chars to free and
points at the one-batch fix.
- An old_text that matched nothing (paraphrased, stale after a concurrent
write, or several entries glued with the delimiter) returned no hint on
the batch path. Zero-match errors now carry `closest_entries` (top word
overlap) on both paths, and flag a delimiter-spanning old_text.
- A batch replace missing its own `content` now says to set it on the
operation object.
Fixes#107270. Same diagnosis as #107278 (@KoNit-K) and #61612 (@webtecnica),
which normalized the round-trip comparison instead of dropping it.
Command STT providers received the raw audio file with no format
normalization: _transcribe_command_stt only checked .exists() before
substituting {input_path}. Desktop voice recordings arrive as WebM/Opus
48 kHz and providers with container/sample-rate contracts (e.g. Tencent
16k_zh) reject the raw webm, so desktop voice chat failed on every
message. _transcode_audio_for_stt (ffmpeg -> 16 kHz mono m4a) existed
but was wired only into the OpenAI path's rejection retry.
Add stt.providers.<name>.normalize (default off, so whisper-style CLIs
that accept any container keep the original file): when truthy, the
input is transcoded into the per-call temp dir and {input_path} points
at the normalized file. Transcode failure returns an error envelope
instead of silently feeding the raw container to a provider the user
declared needs normalization.
Fixes#81811
Co-authored-by: embwl0x <embwl0x@users.noreply.github.com>
Teknium ruled the post-admission consumer hook (#129958) must fail OPEN, so
reshape the salvaged implementation into one small seam:
- `{"action": "handled", "reply": str|None}` is the only decisive result;
None, any other value, a raising callback or a timeout runs the ordinary
agent turn. One buggy plugin can no longer blackhole every inbound
message on a profile (the cherry-picked version failed closed on any
exception or malformed return from ANY registered callback).
- Fire under the message's ROUTED profile scope. The receiving bot's
handler binds the transport home so auth can read its .env; when that
bot serves a chat for another profile, the ambient scope at the hook was
the wrong profile and that profile's consumers never ran.
- Snapshot-only payload (session_key, platform, source dict, message_id,
text): no live gateway runner / session store handles, so the plugin
isolation host needs no HOST_DEGRADED_HOOKS entry.
- Bounded by plugins.hook_callback_timeout (fail-open) instead of the
fail-closed policy-hook set; the required-but-discarded receipt, alias
table, destination snapshot/restore machinery and hard-coded English
failure notice are gone.
- Tests trimmed to three invariants through the real _handle_message and
real plugin dispatch: consume path, raise path proceeds, routed profile
scope bound.
One observer pair fires wherever the agent blocks waiting on a person, so a
notifier plugin no longer needs a hook per prompt type or a `sudo` regex over
terminal commands (#132333, consumer: pushover-hermes-plugin).
Fire sites are the existing choke points every surface already routes through:
- sudo: tools/terminal_tool_sudo.py::_prompt_for_sudo_password (CLI callback,
Ink TUI/Desktop sudo.request card, /dev/tty fallback)
- clarify: tools/clarify_tool.py::clarify_tool (CLI, TUI/Desktop, gateway)
- approval: tools/approval_prompt.py::prompt_dangerous_approval (CLI/TUI/ACP
callback and input() paths), ::_present_with_selected_transport (plugin
transports), tools/approval_gateway_wait.py::_await_gateway_decision
(gateway platforms)
Payload: kind, request_id (shared by the pair), session_id, session_key,
platform, prompt; resolved adds outcome. prompt is always force-redacted and
the typed password / clarify answers are never passed. Smart (aux-LLM)
approvals and coalesced gateway followers do not fire it: no new human prompt.
Closes#132333
The protected agent-instruction write prompt and the MCP/vault consent
prompt called prompt_dangerous_approval directly on the classic CLI
without firing pre_approval_request / post_approval_response, so
observer plugins (notifiers, audit loggers) could not see those human
waits in the terminal even though their gateway twins were visible.
Fire both hooks around the CLI prompt with the same payload shape the
gateway branch builds (pattern_key protected_instruction_file /
mcp_elicitation, surface="cli" or the caller's consent surface); the
post hook fires in a finally so the wait always settles for observers
even when the panel itself raises.
Also document the consent surface values (mcp-elicitation/<server>,
mcp-trust/<server>, vault-payment) and that the Ink TUI/Desktop report
surface="gateway" today.
Fixes#131876
Skill Sync only worked against a Nous Portal sync plane that was never made
publicly accessible, so no install has ever used it. We don't want it in core
right now, so this deletes it outright instead of moving it behind a plugin
hook surface nobody needs yet:
- `hermes sync` command, `tools/skills_sync_client*.py` and their tests
- the personal pull ticks (gateway housekeeping, classic CLI startup,
`hermes serve` maintenance) and the debounced post-write push
- the per-skill `sync` usage flag (`set_sync` / `is_sync_enabled`)
- the `skills/_org/<org_id>/` mirror: index gating on `.active_org`, the
`[org-shared]` / name-collision labels, the org provenance header and
`org_provenance` field on skill_view, the org delete guard and the
auto-propose `org_sharing` note on skill_manage
The code is preserved in the private NousResearch/hermes-skill-sync repo if
the feature comes back.
hermes -z ends its turn but leaves the Relay root conversation open:
run_conversation keeps the root resumable and the one-shot exit is
os._exit, which skips the atexit finalizer every other CLI path relies
on. Exported Relay telemetry showed session start + turn start/end with
no session end, and on_session_finalize never fired for one-shot runs.
_run_agent now keeps the session id at turn entry (compression can
rotate agent.session_id without opening a second root) and _close_agent
calls finalize_session for it after the background-completion linger and
before memory/agent teardown, on failure as well as success.
Co-authored-by: Soheil Fakour <soheil.fakour@gmail.com>
install --force --ref is the documented way to move a pinned plugin, but it replaced the whole plugin directory: untracked and git-ignored files (plugin state, local config) were deleted and tracked edits were lost with no copy. A reinstall from the source the plugin was installed from now carries user files like update does and copies tracked edits to plugins-backup/. A different source still starts clean.
The skills page said local skills win over external_dirs in one place and
listed project > local > external_dirs (no create_dir) in another, while
skill_view refused both. Document the single order every surface now uses
(project > local > skills.create_dir > external_dirs), how same-directory
duplicates are listed and loaded, and that plugin:skill names are unaffected.
Review and simplify pass on the guest fast-search restore.
- tools/tool_backend_helpers.py: _account_info was split out so two gates
could share one account read. Only managed_nous_tools_enabled is left, so
fold it back into its pre-salvage shape. Keep the branched force_fresh
call, because tests stub the reader with zero-arg lambdas.
- plugins/web/perplexity/provider.py: the managed= parameter on
_managed_gateway only fed the removed refusal helper. search() now calls
resolve_free_search_gateway() directly once it has the route answer. The
module docstring now names guests.
- hermes_cli/nous_account.py: the _error_info docstring described the
removed gate. The tier carried on a failed lookup still picks the "needs
a Nous account" copy over billing or re-login guidance.
- Tests: the case-insensitive tier match and the tier on error snapshots
lost their only coverage when the gate tests went, so cover them at
their consumer, format_nous_portal_entitlement_message. The fast-search
premise now asserts that the guest case really reads as the anonymous
tier. Remove the dead exp_offset parameter.
- Docs: the Perplexity section said there is no anonymous tier, which is
true for direct keys only. It now names the managed guest route.
The free managed Perplexity fast-search route exists for anonymous guest
Portal users. The salvage of #130121 (cee3000c24, via #130660) added a
registered-account gate, fast_search_entitled(), that excluded the
anonymous tier. That defeated the original change: guests fell back to
the keyless ring and never sent a gateway request.
This removes the gate, so resolve_free_search_gateway() decides on
identity alone again, as rewbs wrote it. The guest-disabled, expiry
refresh and dead-guest rules still apply through the token reader. Every
other vendor, and the billed managed-Firecrawl fallback, keep the paid
entitlement gate.
Kept from the salvage: the case-insensitive is_anonymous_tier, the
account tier carried on error snapshots, and the single route walk per
search dispatch. Tests: the anonymous identity is back in the autodetect
and rescue cases. The tests that pinned the guest exclusion are removed.
plugins.isolation is profile config, so a profile (or its agent) can set it
back to in_process. The managed scope already beats profile config and makes
`hermes config set` refuse, and the isolation reader goes through that
overlay; document it as the operator lock and pin the contract with a test
(red when isolation_mode() reads the raw profile file).
Control verbs with trailing words now run the verb instead of replacing
the goal, so goal text that starts with one of those words needs the
`--` escape. Without a docs line the change is only discoverable from the
in-chat hint.
A recurring job with a finite repeat count (for example repeat=3, every
24h) lost one repeat slot for each automatic re-run after a fire that
could not reach the model. The failed occurrence counted, and then each
5/15/30-minute re-run of that occurrence counted again, so a half-hour
outage finished a repeat=3 job with zero model calls. With
cron.retry_unreachable off, the same outage costs only the one failed
occurrence.
Cause: cron/jobs.py:_advance_after_run bumps repeat.completed on every
mark_job_run, and the re-runs parked by
cron/unreachable_retry.py:plan_retry go through it like scheduled
occurrences. cron/unreachable_retry.py:will_retry also treated a re-run
on the last slot as the job's final run.
Fix: cron/scheduler.py:_finish_completed_run passes ladder_rung=True
when the dispatched instant is the parked re-run instant (new
is_retry_run, which compares the dispatch snapshot's _scheduled_instant
with the ladder state). mark_job_run then calls _advance_after_run with
count_run=False, and will_retry leaves that run out of its final-run
check the same way. The first failed occurrence still counts, and manual
runs still count.
(cherry picked from commit 27038f7064bb611811efee8c527dddecd4fcb57b)
The bare 'custom' arm of _validate_cron_base_url returned early as pure BYOK, but the resolver attaches the host-gated env keys (OPENAI, OPENROUTER, OLLAMA, <VENDOR>_API_KEY) to such a job by hostname. A job could pair one with another scheme, port or subdomain. When a stored key would go with the base_url, require the origin of a configured endpoint or a registry provider; a base_url no stored key matches stays unrestricted.
(cherry picked from commit bb7c37c8d7b88077acd45de74f1efb1501adca1a)
_validate_cron_base_url accepted a job base_url whose hostname matched
the provider's endpoint or was a subdomain of it, so a cronjob tool call
could pair the stored key with plain HTTP, another port, or a subdomain.
Compare the full origin (scheme, host, effective port) via
utils.base_url_origin, the rule the other secret-to-new-URL checks use.
The scheduler backstop calls the same validator, so every surface that
stores a job is checked before the key is sent.
(cherry picked from commit a281efeedac02f87e168743b389b78f6feaf5472)