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.
Greptile on b17398db089:
- `enforce_turn_budget` took `len(content)` of every tool message. An
image-bearing result reaches it as a part list, so it counted parts,
not characters: six 60K-char MCP results with screenshots totalled
~18 "chars" and nothing was spilled. Part lists now count their text
characters and spill their largest text part (images stay inline).
- `_persist_multimodal_text_parts` spilled every text part under the same
tool_call_id, so with a small `tool_budget.mcp_result_size_chars` the
notes part overwrote the tool text's spill file. Each spilled part now
gets its own id.
- When `vision.max_calls_per_image` refused every image, the refusal lines
were appended after the handler's JSON closing brace. They now go into
the envelope's `result`.
Greptile on 1c7dfff98e7: the scale notes sat at the end of the
envelope's single text part. When the tool text is over the result-size
limit, `_persist_multimodal_text_parts` spills that whole part to a file
and keeps only its head, so the resized screenshot stayed attached
without the "multiply coordinates by N" map and clicks would land on the
wrong pixel. The header and notes are now their own short text part,
which stays inline.
Greptile on 0ce63424a06:
- Only the first four images of a result were prepared; a damaged or
already-in-context image among them hid a valid fifth. Images now
prepare in waves sized to the free slots (bounded at 16 candidates), so
a skip hands its slot to the next image.
- The attach check compared against the 20 MB hard cap, not
`vision.embed_target_bytes`; a best-effort resize that bottomed out
above a low budget rode history anyway. It now checks the configured
target (clamped to the hard cap).
- `tools/computer_use/vision_routing.py` had become a one-line negation
of `_native_tool_result_images`. The capture boundary calls the shared
gate directly; the module and its tautological agreement test are gone,
and the route-decision cases move into the capture-routing tests where
they drive `_should_route_through_aux_vision` itself.
Greptile on 6c01b824fb: moving the prep onto `_vision_cpu_executor`
dropped the calling session's ContextVars. The active runtime is
context-local (`set_runtime_main`), so a pool thread fell back to the
process default and `_supported_media_types` no longer saw a managed
llama.cpp runtime: a small WebP was attached unchanged, and stb_image
cannot decode it (the model confabulates). Profile-scoped vision settings
had the same gap. Each job now runs in `contextvars.copy_context().run`,
as the delegation and MCP-loop paths already do.
Greptile re-review of 6771a0e681:
- A polled MCP screenshot tool re-caches identical pixels under a new
path on every call, so each copy was embedded and re-sent on every later
request, past the per-image cap (3 in delegated children by default).
Each attachment now reserves through `repeat_refusal`, keyed on the
resized data URL (same pixels, same key); a refused image keeps its
MEDIA: path with an "already in context" line.
- Decode/resize ran on the tool thread, so a parallel tool batch of
image-heavy MCP calls could decode many large images at once on a shared
gateway. Prep now runs on the bounded `_vision_cpu_executor`
(auxiliary.vision.max_concurrency), as vision_analyze's encodes do.
Independent review follow-up:
- The shared resizer is best-effort: its 64 px short-edge floor keeps a
60000x64 image at 60000 px, and the MCP path checked only bytes, so an
over-limit part could enter history (providers reject >8000 px per
turn and the reactive shrink cannot fix it). The attach path now
refuses any image whose output long edge is still above 1568 px; the
result stays the MEDIA: text.
- A resized screenshot lost its coordinate mapping. The MCP path now
passes scale_out and lists `_build_scale_note` per MEDIA: path, as
vision_analyze does, so a cua-driver-over-MCP click lands on the screen
pixel the model saw.
- computer_use cached its route per (home, provider, model) for the
process, so editing `image_input_mode` (or the aux backend) never
reached the next capture. The cache is gone: the decision reads the
signature-cached `load_config_readonly` per capture. Pre-existing on
main (12ad13ddca), fixed here because this PR claims every image tool
honours the setting; the perf test that pinned the stale cache becomes
the "config change applies without restart" invariant.
Review follow-up on the MCP native-image path (Greptile):
- A JPEG/WebP/GIF with a valid header over a truncated pixel stream passes
both the cache's magic-byte check and the sniff, and one undecodable part
makes the provider reject the whole request. The cached file is now
fully decoded (`_validate_raster_image_decodable`, as vision_analyze)
before it is attached; on failure the result stays the MEDIA: text.
- tools/AGENTS.md names the multimodal envelope as the one sanctioned
non-string handler return, routed through `_native_tool_result_images`.
- The invariant tests register the fake server with
`_register_server_tools` and call `registry.dispatch`, so dropping
`native_images=True` at registration goes red. A truncated JPEG case
pins the decode check (red when the check 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.
kokhlo's measurement on the trimmed PR: the zero-delivery gate also catches
type_text_synthesis_budget_exceeded, whose delivered_chars is pinned to 0
because the bounded synthesis budget declined to emit at all. That target is
not dropping synthetic keystrokes, and re-routing it to set_value replaces the
driver's own chunk recommendation with a different write path and a wrong
explanation. The gate now keys on the driver's own partial-delivery verdict;
the hint also names set_value as an action rather than a delivery rung.
When a type action delivers 0 of the requested characters, the field is
dropping synthetic keystrokes at the source (trusted-event checks on web
inputs). No delivery rung can fix that; the AX set_value path writes the
value directly and bypasses event filtering, so the escalation verdict
now recommends it instead of the generic px/foreground ladder.
Trimmed from #103157 / #103174 to the unique zero-delivery delta; the
snapshot_id element addressing landed via #111313 (element_token) and
#116367 (snapshot_id carrier).
Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
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.
Two content-level shapes the dangerous-command detector missed and that only the
bundled tirith scanner caught:
- a curl/wget request body carrying a secret-named variable or a credential file
(-d "k=$OPENAI_API_KEY", -F file=@.env, -T ~/.ssh/id_rsa, --post-file=/etc/passwd),
or a credential file piped into an uploading curl/wget. Authorization headers are
ordinary API use and stay unflagged; flag letters are case-sensitive (-D/-f do not
upload).
- zero-width / bidirectional Unicode controls that make the approved command differ
from the executed one. Emoji ZWJ sequences are not flagged.
Measured on a 40-command corpus: core now catches 13/16 attacks (was 9/16; tirith
0.4.2 caught 14/16) while flagging 5/24 benign commands (tirith flagged 12/24).
Offset the FILE_LINES growth the moa preset fixes add to hermes_cli/config.py
(4283 > its 4274 cap): the version-stamp trio (raw read of _config_version
plus check_config_version) moves verbatim into topical sibling
hermes_cli/config_version_stamp.py; config.py re-exports the two public
names (callers and tests import them from hermes_cli.config), the internal
_read_config_version_stamp callers now use read_config_version_stamp, and
scripts/docker_config_migrate.py imports from the new module directly.
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>
Greptile 4198572811: the guard now finds subprocess/os/asyncio calls as ast.Call nodes, so multi-line calls are checked, and the 27 calls it newly flagged (pixel-art ffmpeg and 26 others) pass stdin=DEVNULL.
tools-reference.md: gui_tour row and Tours section describe bare start, preset quick|full, and custom tours.
tour_tool.py: preset together with steps is an error instead of being ignored.
test_tour_tool.py: through the registered handler, an invalid preset and preset+steps are rejected and a valid preset reaches the request.
A general look around the desktop app is now one tool call. Before, the
model ran a targets scan and wrote its own steps from raw DOM labels: two
round trips, English-only copy.
- gui_tour action='start' without steps runs the built-in tour on
surface='app' (refused on 'preview'). preset 'quick' shows four stops:
chats list, composer, new session, model picker. 'full' (the default)
adds what the interface mode puts on screen. A stop that is not on
screen is skipped. The model stop adds a line when this computer can
run a local model (the local-setup tip's fit check, capped at 2 s).
- TourPreset is defined once, in tools/tour_tool.py. The wire contract
types tour.preset with it, so the renderer uses the generated
TourPreset type. Contracts regenerated.
- The stop copy lives in i18n (appTour, en_app_tour.ts); other locales
fall back to English until the translations PR.
- engine.ts: one shared teardown for every driver exit, which now tells
the host. The app host clears a reactive $tourActive flag, so the
usage-stats consent dialog waits until no tour is on screen. A closed
one-off highlight also clears it.
- en.ts and types.ts are over the size cap, so the existing handoffTour
copy and type move unchanged into the same siblings to offset appTour.
test_start_validates_its_steps: a bare start is no longer an error; it is
the built-in tour, and start on surface='preview' without steps is.
An agent stores its session platform's gated toolsets in disabled_toolsets at load, so the tool_search listing, the bridge's search, MCP refreshes and delegate children drop manage_catalog too.
InstallPhase moves to the leaf hermes_cli/plugin_install_phase.py; the gateway contracts no longer import the installer.
The per-home enable lock moves from the plugins_cmd facade to plugins_cmd_install._enable_placed.
Reusing an installed catalog tree reads the install record through catalog_install_record, so older top-level catalog_name records match.
A failed row no longer redacts the Advanced choices (profile name, commit); only credentials are redacted.
The profiles.py role comment no longer says a role grants the setup toolset.
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.
BUILTIN_STT_PROVIDERS and the transcription registry both list deepinfra, but
the CLI readiness gate in tools/voice_mode.py consults its own hardcoded
_NATIVE_STT_LABELS map, which never gained the entry — so check_voice_requirements()
reported 'STT provider: MISSING' and /voice on refused to arm the recorder while
the same config transcribed fine on the gateway.
Also extends the builtin-sync relationship test to cover the voice-gate label
map, so the next built-in STT provider cannot ship with a silently dead CLI
voice gate.
(cherry picked from commit f65c9e65ea6c5684cc6c55af59137dd10ad0869c)
xAI's /v1/stt now answers HTTP 400 "Field 'language' is required when
'format' is true", so every transcription with stt.language "" (auto-detect)
failed on both the server path and the Desktop client-direct path. Send the
inverse-text-normalization flag only when a language is resolved; the model
field is still always named. Comments updated: the server default moved from
grok-voice-transcribe-1.0 to 2.0 between Sep 17 and Sep 21, so the pin is
about determinism and a 1.0 rollback knob, not about escaping a 1.0 default.
Live: auto-detect on origin/main -> HTTP 400; with this change -> transcript
returned for 2.0 and pinned 1.0; language=en still sends format=true.
(cherry picked from commit 1ff4887819a12a5e37e9cbea7f31b5cc6b040857)
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)
Two skills sharing a frontmatter display name must not resolve by that
name in skill_manage (silently mutating the wrong skill); a unique
display name resolves and the directory names keep working. Mirrors
skill_view's same-tier collision refusal semantics in the manager.
skills_list displays a skill's frontmatter name: when it diverges from
the directory name, every mutating skill_manage action (and the
/skills approve replay) called with that displayed name failed with a
misleading 'not found in active profile' error — nothing about profiles
was actually wrong (#99609).
_find_skill now falls back to matching the SKILL.md frontmatter name
after the directory-name/categorized scan finds nothing, with the same
MAX_NAME_LENGTH truncation skills_list applies when displaying it.
Directory-name matches keep priority (recorded only, returned after the
full scan), so one skill's frontmatter cannot shadow another skill's
directory. This also makes _create_skill's duplicate check catch a new
directory name colliding with an existing skill's displayed name — a
collision skills_list's seen-names dedup would silently hide.
A deployment reported that write_file -> read_file (full) -> patch ->
write_file refuses the last write with stale_write_blocked and "exists
but this task has not seen its full current content", in 50 of 50 trials.
No other writer took part. When the model does not reread before the turn
ends, the refusal surfaces as the "File-mutation verifier: N file edit(s)
FAILED" footer.
Mechanism. The write_file baseline is an exact file version: (st_dev,
st_ino, st_size, st_mtime_ns, st_ctime_ns, sha256)
(tools/file_tools_read_tracking.py _file_version, _mark_full_write_baseline,
_has_full_write_baseline). write_file_tool marks a fresh baseline from the
bytes it wrote. patch_tool only ran _note_edited and
_reset_patch_failures, so the task's own patch changed the version and
left the baseline it held stale. _stale_overwrite_blocker then treated the
task like an external writer.
Invariant: after a task's own successful patch, the task may replace the
file whole exactly when it held a current full baseline for the bytes the
patch read and the bytes the patch wrote are what is on disk.
Lineage. a8af57fcd2 added the guard. dddefaefae counted paged, extracted
and post-compaction reads. e636aedebf kept a baseline across narrower
rereads of unchanged bytes and bound it to the sha256. That is the change
that made this sequence refuse (its parent lets the write through). Own
patches are the missing case of e636aedebf's principle that unchanged
knowledge survives.
Shape:
- Under the per-path locks, before the patch reads, patch_tool records
the sha256 of each path whose baseline still matches disk
(_known_full_content_sha256).
- read_file_raw and patch_replace report the sha256 of the exact bytes
they read, BOM included. Each file write records (path, read sha256,
written sha256) in PatchResult._writes for replace, V4A Update and V4A
Add.
- _carry_full_write_baselines marks the baseline only when the read
sha256 equals the known one (or the patch created the file). The mark
goes through _mark_full_write_baseline with the written sha256 as
expected_sha256. A writer that lands before the patch reads leaves a read
sha256 the task never knew. One that lands after the patch writes fails
the expected-sha check. Neither case gets a baseline. Several operations
on one file in one V4A patch chain in order.
- A blind patch (no baseline, or a partial view) is noted per task in
blind_patches. While disk still holds exactly those bytes, the refusal
says "Your patch changed this file after a partial view. Read the
current file in full before a whole-file overwrite, or continue with
targeted patches." Sibling, external and never-read refusals keep their
wording.
Remote backends: the baseline logic already stats the host path. On a
sandbox backend (non-LocalEnvironment env, verified with the
remote-binary-guard transport double), read_file records no baseline,
and _stale_overwrite_blocker lets the write through because the host path
does not exist. The carry hashes the same host path, so it records
nothing there. Paths the backend rewrites (translated mounts) do not match
the resolved host path and are skipped. Behaviour on those backends is
byte-identical to before.
Hint contradiction: after three failed replace patches, option (3) said
"use write_file to replace the entire file", which is the call the guard
refuses after a patch. It now says "re-read the file in full, then use
write_file to replace it".
Out of scope: enforcing recovery from a stale_write_blocked refusal
before a turn ends. The stale_write_blocked receipt and the file-mutation
footer are unchanged.
Address @antifragileer's review suggestions on #44498:
1. test_partial_write_does_not_mutate_existing_list: pins that a failed
write with mixed valid/invalid items leaves the prior list untouched
(acceptance criterion #3 from #44496).
2. test_merge_new_item_rejects_whitespace_content: verifies whitespace-only
content is rejected for new items in merge mode, guarding against a
future code change that special-cases merge.
Two mocked tests drive _legacy_kill_process_tree down its shared-group
branch with a MagicMock proc whose pid is 999. That branch snapshots
psutil.Process(proc.pid).children(recursive=True) and SIGKILLs each
descendant, and psutil was never patched: the tests killed the real
descendants of whatever the machine runs as PID 999. On macOS runners
PIDs are allocated system-wide, and when 999 was an ancestor of the
test worker the file died mid-run (CRASHED, killed by SIGKILL), which
is how it surfaced on a PR whose diff never touches the browser tool.
Both tests now run against a fake process table, and the shared-group
test asserts the snapshotted descendant is killed individually (the
contract it was written for).
Live repro: inside an unprivileged user+PID namespace with PID 999 an
ancestor of the test runner, main's file reports CRASHED (killed by
SIGKILL); with this change it passes 9/9.
Two display bugs in the web provider picker (Desktop Capabilities panel,
dashboard, `hermes tools` cursor):
- Rows sharing one backend name: cloud "Firecrawl" and the
"Firecrawl Self-Hosted" setup row both carry web_backend: firecrawl, so
web.backend: firecrawl lit both and the picker defaulted to Self-Hosted
on cloud-key installs. The row whose env var is set is now the active one;
the cloud row stays active keyless (explicit firecrawl with no key runs
anonymous cloud) unless a sibling's var is set instead. (#112022)
- Keyless rows read "ready" vacuously (empty env_vars). The native provider-side
search row now reports needs_auth until its provider login exists,
matching its registry is_available(). Free-tier keyless rows stay ready: live calls
through exa/keenable/parallel free tiers succeed. ddgs already reported
needs_setup on main. (#132526)
Co-authored-by: timmycheng <9245654+timmycheng@users.noreply.github.com>
The managed web row answered from the shared web.backend only, so
extract_backend: nous (Desktop "Use for Extract" on Nous Subscription)
left it dark. It now reads the same per-capability resolution as the
vendor rows (incl. legacy use_gateway), shared in _web_serving_backends.
Tests folded into two invariants.
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
Plugin-registered TTS providers only ever reached the sync dispatcher, so a
local engine plugin (Piper, KittenTTS, a self-hosted server) could not stream:
every spoken reply waited for whole-utterance synthesis even though the engine
emits audio per sentence. tts_streaming's resolver only ever looked at its own
built-in registry.
A registered TTSProvider that opts in with
streams_pcm = True # stream() yields raw int16 mono PCM
stream_channels = 1
stream_sample_width = 2
@property
def stream_sample_rate(self): ... # Hz, from the model itself
is now adapted into a StreamingTTSProvider by _plugin_streamer(), so it feeds
the CLI/TUI speaker pipeline, the gateway StreamingTTSConsumer and the desktop
speak-stream WebSocket exactly like a built-in chunked provider
(elevenlabs/gemini/openai/xai).
- a provider that sets streams_pcm without a positive stream_sample_rate is
refused (raw PCM played at a guessed rate garbles speech) and falls back to
per-sentence synthesis
- the "never silently swap the user's voice" invariant holds: a pinned provider
name that cannot stream returns None instead of substituting another provider
- tts.streaming.provider: auto falls back to the configured provider's plugin
streamer only after the built-in priority list is exhausted
Tests cover adaption, the missing opt-in, the missing sample rate, a pinned
unusable name, a pinned plugin name, and the auto fallback.
* fix(process): a kill's result stands on every backend, and a failed scope stop never counts as stopped
Follow-up to #133434, which fixed these races for local processes only.
Non-local backends (spawn_via_env) settle through _env_poller_loop, which
still wrote the session's final state without the session lock:
- It set exit_code before _finish_exited, so a kill that landed first ended
as killed with the sandbox's exit code. The exit now goes through
_finish_exited alone, and a committed kill keeps its code.
- Its backend-lost path set exited/-1/lost directly, replacing a recorded
kill. It now goes through _finish_exited(reason="lost"), so the kill stands
and the completion is still published by its owner.
- A log delta (or a log-rotation reset) fetched before a kill was applied
after it, so the session's output no longer matched the kill's snapshot.
Both are now dropped once the session has exited, decided under the lock.
A failed systemd scope stop could still count as a successful stop:
- A recovered PID that died between get() and _signal_kill ignored the stop
result and returned a plain already_exited. It now reports
scope_stop_failed.
- kill_all counted already_exited as stopped even when the scope stop
failed, and only looked at _running, so a session that exited after being
picked kept a live scope that no later sweep retried. Every kill path now
stops the scope through _stop_scope, which marks a failed stop pending;
kill_all does not count a result with scope_stop_failed and also retries
finished sessions whose stop is pending.
_log_delta_command moves unchanged into tools/process_registry_env_log.py
(ProcessRegistry._log_delta_command stays as an alias) to keep
process_registry.py under its size cap.
Refs #132358. Both gaps were found by @andrexibiza in the review of #133434.
* fix(process): pruning keeps a session whose scope stop is pending
kill_all retries a failed scope stop through the finished session, but
_prune_if_needed still evicted that session by TTL (measured from process
start, so a long-running process was prunable the moment it finished) or
by capacity, leaving a possibly live scope with no retry target. Sessions
with _scope_stop_pending are now exempt from both until the stop succeeds;
capacity pruning drops the oldest prunable session instead.
Found by the independent review of #133648.
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.
Plugins that drive the agent's browser below the browser tools (Secure ENV,
catalog review on #132272) had no public way to send a raw CDP command over a
task's supervisor connection, so they bumped CDPSupervisor._next_call_id,
inserted futures into _pending_calls and sent on _ws. That breaks silently on
any internal change and collides between plugins.
SUPERVISOR_REGISTRY.capture(task_id) now returns a CapturedCDP pinned to the
supervisor's current socket and default page session:
call(method, params=None, *, session_id=None, timeout=10.0) -> raw reply
is_valid() / page_session_id / task_id / cdp_url
The validity check and the send run in one supervisor-loop step, so a call
never reaches a newer connection: after a reconnect, stop or registry
replacement every call raises CapturedCDPInvalid. Calls in flight when the
socket drops now fail at once (the supervisor fails pending replies on
teardown) instead of sitting out their timeout.
No model tool, config default or new capability: in-process Python could
already reach this. The handle enforces no origin/consent/ownership policy.
Reduced-scope salvage of #132752 (API shape and names from that PR); its
attach-reply handoff, old-wire cleanup and dispatch fences are not needed
because the supervisor closes the old socket on every reconnect, which drops
its sessions with it.
Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com>
Screen finding on #131927: when QQ media delivery failed (stale local
path, daily upload quota, oversized file, or any upload/API error),
_send_qqbot's media path returned a bare error and the caption/message
text was never delivered — a regression against main, where QQ
text+media degraded to the text delivered plus a 'MEDIA attachments
were omitted' warning.
- collect per-attachment failures as warnings and keep delivering
(previously the first failed attachment aborted the whole send)
- when nothing was deliverable, fall back to sending the text or the
caption standalone, matching the pre-media generic path
- surface collected warnings on the final result (and on error returns)
Tests: missing-file, upload-quota and generic-delivery failure flows
assert the text still lands exactly once with a warning (red on the
PR head before this fix).
Introduced a new test suite for QQ Bot media handling, including file type detection and media routing. Enhanced the send_message_tool to support media uploads for QQ Bot, allowing for both direct media messages and text with media attachments. Updated error handling and added support for media delivery in C2C and group chats.
Review fixes for the generic-dir fallback:
- SkillsShSource.fetch now names a generic-dir bundle after the slug too, so a skills.sh install
that bypasses the index no longer lands in a shared skills/ dir.
- HermesIndexSource.fetch applies the slug name whether the row resolved via resolved_github_id
or fell back to the tree, so a rebuilt index does not create a second lock entry ('skills').
- The tree fallback only runs for skills.sh rows: a deleted concrete GitHub path stays unavailable
instead of installing whichever skill remains in that repo under the requested name.
- _find_skill_in_repo_tree's lone-SKILL.md fallback only accepts a generic dir (skills,
.agents/skills, .claude/skills); a lone named skill (skills/bar) no longer answers for slug foo.
- The PR's two tests move to tests/tools/test_skills_hub_generic_dir.py (test_skills_hub.py is at its
code-health line cap).
skills.sh rows in the hermes-index carry the skills.sh slug as `path` and,
since mid-September, never a `resolved_github_id`: the scheduled index build
resolves paths AFTER the ClawHub walk, ~80 min into the job, when the 1 h
GitHub App token has expired. Every tree fetch 401s and the log says
"Resolved 0/20000 paths" (last good run 2026-09-15: 17917/20000). Installs
then work only where the repo dir happens to equal the slug, and everything
else is reported as a "stale index entry".
- build_skills_index: resolve skills.sh paths straight after the skills.sh
crawl, while the token is fresh.
- HermesIndexSource.fetch: when the indexed candidates miss, locate the skill
in the repo tree (slug-named dir, a lone SKILL.md in a generic dir such as
`skills/`, or the repo root) and install it under the slug, not as
`skills/`.
- _find_skill_in_repo_tree: a tree with exactly one non-root SKILL.md
resolves to it when no dir matches the slug (fails closed on 2+).
- GitHubAuth: once the credential chain came up empty, stay anonymous for the
instance instead of re-running `gh auth token` before every API call; with
gh installed but not logged in that cost up to 5 s per request and pushed an
anonymous install past four minutes.
Fixes#130129
Based on #130135 by @dskwe.