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
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.
Groundwork for user-defined columns and workflow per board (#54818).
- hermes_cli/kanban_workflow.py: Column/Workflow + DEFAULT_WORKFLOW, today's
board written down as data (column order, traits, default kernel-event
edges, manual move allow-list). Not consulted by the kernel yet.
- VALID_STATUSES, dashboard BOARD_COLUMNS, the kanban_list status enum and
CLI status icons now derive from it. Fixes drift: the tool enum lacked
scheduled/review; CLI icons lacked triage/review.
- GET /workflow on the dashboard plugin API.
- Tests pin DEFAULT_WORKFLOW to real behavior: every default event edge vs.
its kernel verb, and all 64 PATCH src->dst moves vs. can_move.
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.
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).
The agent's terminal puts a sealed payload's launcher dir first on PATH even when the inherited PATH already lists it behind another install.
The macOS removal instructions name the running .app bundle and its folder instead of a fixed Hermes.app.
A manifest.json with a mistyped runtime, commands, venv or store reads as not a payload, and a missing venv or store is treated as unset, so startup no longer raises.
Inside a packaged desktop build, bare `hermes` in the terminal tool resolved
to <payload>/venv/Scripts/hermes.exe. That venv was built on CI, and its
Windows redirector reads pyvenv.cfg `home`, which still names the CI path,
so every call failed with "did not find executable at C:\a\hermes-agent\...".
The working launcher in <payload>/bin carries the channel name and was never
on the agent's PATH.
- activate_dependencies puts the payload's own launcher dir (from the
manifest's runtime.commands) first on PATH, and on Windows no longer adds
the shipped venv's Scripts dir: every redirector in it names the CI
interpreter.
- _resolve_hermes_bin_dir answers with that launcher dir in a sealed payload,
so terminal and cron children do not depend on PATH order.
- stageDesktopLaunchers copies the canonical launchers to their qualified
names instead of renaming them. Only the qualified names are published.
- The "plugin already exists" hint names the real CLI command.
- Every manifest-relative path (venv, store, hermes launcher) goes through one
resolve-and-contain check, and a manifest.json that is unreadable or not a
payload manifest reads as "not a payload" instead of raising, since every
install now asks.
Source and dev installs are unchanged: they have no payload manifest.
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.
Re-check the active chat after the local-model wait and answer 'Tours only run in the session the user is looking at.' when it changed.
Move TourPreset to tools/tour_presets.py so importing the wire contracts no longer registers gui_tour.
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`.
STT_XAI_MODEL is read per call (a module constant froze the launch profile env),
the xAI client-direct form builder moves out of transcribeAudioClientDirect (CC 21),
the silencedetect probe catches only what subprocess raises, and the stt.xai
defaults comment folds into one line so config_defaults.py does not grow.
STT_MODEL_CATALOG moves to tools/transcription_common.py and becomes the one table
behind the `hermes tools` picker, the dashboard selects and the auto-correction
sets. Before this, the dashboard and Desktop offered models for only four providers,
Mistral and xAI had no select, DeepInfra had no model field, and Groq still listed
distil-whisper-large-v3-en, which Groq shut down on 2025-08-23. A config naming it
now remaps to whisper-large-v3-turbo.
Mistral Voxtral STT comes back to the picker and the dashboard provider list. It had
been hidden since the 2026-05-12 mistralai quarantine, and the pin is a verified-clean
2.4.8 again. DeepInfra's picker reads its live STT catalog.
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)
An explicitly-set empty allowlist meant 'nothing allowed' everywhere in
prose, but every boundary normalized it away:
- tui_gateway _load_enabled_toolsets converted an empty resolved set to
None (= no restriction), so platform_toolsets.cli: [] handed the
session every default toolset (#82010).
- cron create/update normalized enabled_toolsets=[] to null, and the
dispatch resolver treated a stored [] as absent (falsy), falling back
to platform_toolsets.cron.
Distinguish unset (None = no restriction, unchanged) from explicit
empty ([] = zero toolsets) end to end: the gateway returns [] bare (no
client-surface fold-in), cron persists and reloads [] verbatim, and
dispatch resolves [] to zero toolsets with no MCP merge. Absent keys
keep today's defaults.
hermes.extension.install.count gains failure_class (closed per-kind set named at each failure exit
of the skills hub, plugin installer and MCP catalog emitters; raised exceptions classify by their
tagged class or TYPE, exception:<Type> collapses to exception, never the message) and registry
(the skills-hub adapter SOURCE_ID that resolved/served a skill; none/unresolved/other otherwise).
hermes.update.run gains failure_class derived from the FINAL receipt only (admission step,
stop_reason exception type, exit_code, stage marks, fleet states, gateway_restart). failed_stage no
longer reads 'other' for runs whose every mark passed: a 'partial' receipt (written only by the
post-restart verification) synthesizes a failed verify stage, and a failed run ending on a skipped
restart reads restart.
Package schema v4 is extended in place (canary-only): the pre-split field sets stay valid as the
second oneOf shape and in _LEGACY_METRIC_FIELDS so pending rows drain.
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.
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
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.
Fleet telemetry shows built-in memory writes failing 34-47% and automatic
compression failing ~15%, and several merged fixes each target one cause
(#133038 for memory, #133054/#133040/#133051/#133032 for compression). Both
metrics only carried outcome, so the fleet could not tell which cause a fix
moved.
- hermes.memory.op.count and hermes.compression.count gain one closed-enum
dimension, failure_class; `none` on success.
- Memory: every refusal/failure return in tools/memory_tool.py and
tools/memory_tool_store.py names its class (no_match, ambiguous,
over_budget, would_empty, drift, read_failed, retry_cap, scan_blocked,
missing_content, missing_old_text, invalid_args, stale_entry, disabled,
staged, gate_refused) through a context variable; the result the model
sees is unchanged. Plugin providers report provider_error / exception.
- Compression: the attempt log's own failure_class, collapsed onto the
compressor's closed names (exception:<Type> / rollback:<Type> keep only the
prefix, anything else reads other, a classless failure unknown). Skipped
rows carry the skip reason, and the skip set is now defined once in the
contract.
- Package schema v4 / event schema v4; v3 field sets of both metrics stay
valid so pending rows drain. Doc table rows and the consent copy (CLI
setup and every Desktop locale's collectedActivity) name the new reason.
* 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>