Commit Graph

5863 Commits

Author SHA1 Message Date
Mark S.
970ba79fb4 Merge pull request #133975 from victor-kyriazakos/fix/own-patch-carries-write-baseline
fix(tools): a task's own patch keeps its full-file write baseline
2026-10-06 13:14:27 -04:00
Kuxin
0fa1fb600d fix(stt): normalize local command language hints 2026-10-06 12:08:04 -05:00
Kuxin
0d234e714c fix(stt): normalize local language hints 2026-10-06 12:08:04 -05:00
liuhao1024
19b5b411ab fix(skills): resolve the frontmatter name _find_skill ignores
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.
2026-10-06 12:00:47 -04:00
Victor Kyriazakos
2b3afee3fe fix(tools): a task's own patch keeps its full-file write baseline
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.
2026-10-06 15:39:24 +00:00
liuhao1024
ea61bb9ef8 fix(todo): reject empty content instead of substituting '(no description)' (#44496) 2026-10-06 09:53:34 -04:00
Teknium
3861f1da62 fix(web): pick own key or Nous Tool Gateway per web capability in Desktop
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>
2026-10-06 02:53:56 -07:00
Teknium
f9798bcf9b fix(tts): validate a plugin's stream rate once; keep nous off plugin routing
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.
2026-10-06 00:52:41 -07:00
Teknium
10f5530967 fix(tts): plugin PCM streamers keep the sync dispatcher's voice and routing
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
2026-10-06 00:52:41 -07:00
Moninja
65ea2324af feat(tts): let PCM-streaming plugin TTS providers join the streaming path
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.
2026-10-06 00:52:41 -07:00
Teknium
c99299d746 feat(telemetry): memory and compression rows say why they failed or were skipped
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.
2026-10-05 22:07:41 -07:00
Mark S.
88c6085846 fix(process): a kill's result stands on every backend, and a failed scope stop never counts as stopped (#133648)
* 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.
2026-10-06 00:00:57 -04:00
Teknium
63f5e0ea45 feat(computer-use): pluggable single-select computer-use providers
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.
2026-10-05 17:20:30 -07:00
Teknium
6bf7de73e1 feat(browser): public captured CDP call seam for trusted plugins
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>
2026-10-05 17:10:58 -07:00
Brooklyn Nicholson
ebadb5462e fix(qqbot): degrade media send failure to text + warning instead of dropping it
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).
2026-10-05 18:39:43 -05:00
cadezhou
e167fc33b7 feat(qqbot): add support for proactive media delivery via send_message
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.
2026-10-05 18:39:43 -05:00
Teknium
af4f663ab8 fix(skills): built-in restore stays in the skills tree, marks the skill active, and names a hub install that shadows it
Review fixes on ensure_bundled_skill:
- The copy now goes through the hub's destination guard
  (skills_hub_install._resolve_lock_install_path). Before this, a symlinked
  category dir sent `hermes skills install pdf --yes` outside the profile's
  skills tree and still exited 0.
- A restore of a curator-pruned built-in now sets the usage state to active,
  as skill_usage._relocate does for restore_skill. Before, the record stayed
  `archived`.
- If a hub install holds the built-in's name (the computer-use case),
  install no longer says "the built-in is already available". It says a hub
  skill shadows the built-in, gives the uninstall-then-install steps, and
  leaves the user's hub install untouched. It exits 1 so the Desktop shows
  the reason, and it records no install event.
- tui_gateway skills.manage install: folds the built-in no-op into
  `installed`, keeping methods_tools.py at its line cap on main.
2026-10-05 16:00:48 -07:00
Teknium
53a9fa830d fix(skills): installing a built-in skill by name makes it available instead of failing
`hermes skills install computer-use` (and pdf, google-workspace, python-debugpy,
popular-web-designs) failed for every user who tried it: these names ship in
skills/, but the installer sent them to the hub. There a bare name resolved to
a stranger's same-named skill, an ambiguity table, or a stale index row. The
identifier the public Skills Hub page shows for a built-in
(NousResearch/hermes-agent/skills/<cat>/<name>), and its skills.sh mirror, fetched
this repo's own files and scanned them as community content, so the scan
blocked them.

_install_skill now checks the identifier first. A bare bundled name, this
repo's skills/ path, or its skills.sh mirror goes to the bundled copy. If the
skill is already active, the command says so and exits 0 (outcome None, no
telemetry row). If it is missing (deleted, opted out, or curator-pruned), it is
copied from the bundled source, recorded in the manifest, unsuppressed, and
counted as a successful source=bundled install. An explicit source pin or
--name override keeps the hub path. The connector catalog and the TUI
/skills install RPC treat this result as success, not "not installed".
2026-10-05 16:00:48 -07:00
Teknium
429f966018 fix(skills): one install name per skills.sh skill; never swap a gone GitHub path for another skill
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).
2026-10-05 15:56:28 -07:00
Teknium
d2c05341fc fix(skills): install skills.sh skills from a lone generic dir under their slug; resolve index paths before the token expires
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.
2026-10-05 15:56:28 -07:00
Yuan Li
f176143406 fix(skills): resolve generic indexed skill directories 2026-10-05 15:56:28 -07:00
Yuan Li
b6058d755b fix(skills): resolve indexed skills in generic directories 2026-10-05 15:56:28 -07:00
Teknium
b9013584b2 fix(memory): punctuation-only old_text never selects an entry; consolidate tests
The typography-folded fallback in _substring_matches rejected a needle only when
it was quotes/dashes at the ends (needle.strip("'-")), so old_text="\u201c \u2014"
(curly quote, space, em dash) folded to "' -" and matched any entry containing
`" -`, deleting it via single or batch remove. The fallback now requires at least
one alphanumeric character; exact-entry and byte-substring priority are unchanged.

Tests: the PR's four new tests are folded into two invariants (layout/typography
match + punctuation-only rejection; self-correcting failure copy + #90468 alias).
2026-10-05 15:36:45 -07:00
Teknium
8079fd01f4 fix(memory): stop false drift refusals; refused writes name the exact fix
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.
2026-10-05 15:36:45 -07:00
o3u6x
2983c25373 fix: tolerant memory entry matching for quote/whitespace normalization
Background-review (and human /memory) replaces re-emit entry text with
different quote characters (straight ' vs stored ") or re-wrapped lines, so
byte-exact old_text substring matching wrongly reported 'No entry matched'
and the staged consolidation write failed to apply. Fall back to a
quote/whitespace-normalized match only when no exact match exists, preserving
today's unambiguous behavior. Adds regression tests for the quote-style
replace and for unrelated old_text still being rejected.
2026-10-05 15:36:45 -07:00
liuhao1024
26dd1ddfb3 fix(memory): treat serialized-empty content as unset for the new_text alias
PR #86642 documented the single-op coalescing as
`content = content or new_text`, but the implementation only fell back
when content was None. Runtimes that materialize an omitted optional
string field as "" (some providers fill optional schema fields rather
than omitting them) bypassed the alias, and replace/add calls carrying a
genuine new_text failed with 'content is required for replace action.'
(#90468).

Treat content == "" like None on the single-op alias path, matching the
coalescing the batch ops already use (op.get('content') or
op.get('new_text')). A non-empty content still wins when both are set.

Fixes #90468
2026-10-05 15:36:45 -07:00
Teknium
351e5f05a0 fix(skills): a revoked GITHUB_TOKEN no longer blocks hub installs; fall through to gh auth / anonymous and name it (#98725)
GitHubAuth resolved the .env PAT first and latched it for the process; GitHub's
401 Bad credentials was returned to callers as a plain miss, so every
GitHub-backed install failed even when `gh auth login` worked or the repo was
public, and the CLI blamed a "stale index entry".

- GitHubSource._github_get: on a 401 for the credential it sent, mark it
  rejected and replay the same request (caller headers kept) with the next
  method, ending anonymous.
- GitHubAuth: skips rejected tokens/methods; records a label per rejection.
  `gh auth token` now goes through git_credentials._gh_cli_token, which strips
  GH_TOKEN/GITHUB_TOKEN so gh returns its keyring login instead of echoing
  the dead env token back.
- _print_fetch_failure: names the rejected credential and never calls such a
  failure a stale index entry.

Credit: @easonlao's #46402 proposed the same fallback against the pre-split
tools/skills_hub.py.
2026-10-05 15:35:10 -07:00
Teknium
ca398ea39e fix(mcp): trim the read-only hint reader; the e2e read-only cell passes
mcp_field(None, ...) already yields None, so the extra None guard is dead.
Drop the #121042 KNOWN entry from the stdio conformance suite: on a
trust: untrusted server the readOnlyHint tool now runs without approval
against the real mcp 2.x fixture server.

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
2026-10-05 15:28:17 -07:00
Sergii Osadchyi
35332931bf fix(mcp): read readOnlyHint from mcp 2.x snake_case annotations
mcp 2.0 renamed ToolAnnotations.readOnlyHint to read_only_hint; camelCase
survives only as a pydantic serialization alias, which is not applied to
attribute access. _annotation_read_only_hint() read the camelCase attribute
off the SDK object, so on mcp 2.x the hint was always None and every tool --
including honestly annotated read-only ones -- fell into the
"unknown = write-capable" branch.

With trust: untrusted that means the approval gate fires before every read
call (get_*, search_*, list_*), which is exactly the failure mode that
trains a user to click through the prompt without reading it, weakening the
gate on the writes it exists for.

Use mcp_field() for the object path (the helper already used across the MCP
client for this 1.x -> 2.x rename) and accept snake_case in the cache dict
too, for caches written by another client. Unknown still means
write-capable; only the field lookup changes.

Regression test covers the SDK 2.x model, both snake_case shapes, the 1.x
camelCase attribute and a snake_case cache dict.
2026-10-05 15:28:17 -07:00
nikkoxgonzales
ffe73c37f0 fix(mcp): make sampling.tools capability opt-in via expose_client_tools 2026-10-05 16:47:32 -05:00
liuhao1024
3c4a46def3 fix(skills): make env_passthrough registrations visible across tool context snapshots
skill_view registers a skill's declared env vars (required_environment_
variables and the legacy prerequisites.env_vars) into the sandbox
passthrough allowlist. The allowlist was backed by a ContextVar, and
tool dispatch fans each tool call onto a worker whose context is a
copy_context() snapshot taken at submit time
(tools.thread_context.propagate_context_to_thread). The registration
therefore lived and died inside the skill_view worker's snapshot: the
submitting thread's context never saw it, and the next tool's snapshot
(execute_code, terminal) always started from an empty allowlist — so a
skill's env vars were never passed through, on any turn, in any session
(#90004). The config-based allowlist (terminal.env_passthrough) is a
module-level global and worked, which is why manually adding the var
there was the only workaround.

Store the skill-registered names in a process-wide set, matching the
config path's visibility. Cross-session exposure is limited to the
names — values still resolve per profile through
resolve_passthrough_value's secret_scope, so a name registered by one
session cannot read another profile's secret. clear_env_passthrough
keeps its reset semantics; a later skill_view re-registers on demand.
2026-10-05 16:46:53 -05:00
beardthelion
2f41514cae Address PR review feedback (#119150)
- process_registry._reap_untracked: delegate to _terminate_host_pid with the
  session's recorded host_start_time instead of a raw killpg -- pgid == pid
  only proves group leadership, and a reaped child recycled onto an unrelated
  group leader would still have been signalled. The helper revalidates the
  kernel start time and refuses on mismatch, and settles descendants from a
  psutil snapshot so no group signal is needed at all.
- browser_tool_lifecycle._legacy_kill_process_tree: in the non-leader path,
  snapshot descendants via psutil before proc.kill() and kill each
  individually -- a bare proc.kill() left grandchildren holding the capture
  pipe's write end, reintroducing the communicate() hang the helper exists
  to prevent.
2026-10-05 16:42:57 -05:00
beardthelion
956cd8dd3e fix(processes): signal a process group only when the child leads it
_kill_process_tree fallback and three latent killpg callers resolved
os.getpgid(child) and signalled the result unconditionally. A child that
shares the caller's group resolves to that group, so TERM+KILL took down
the whole Hermes tree; a reaped-and-recycled pid resolves to a foreign
leader's group. Signal the group only when pgid == pid, the rule
deadline.kill_process_tree and _subprocess_compat already use, and fall
back to signalling the direct child otherwise.
2026-10-05 16:42:57 -05:00
liuhao1024
5b671e29a4 fix(tts): make tts.provider the authoritative backend selector
The per-call provider argument on text_to_speech (added via #73513,
closing #47459) let the model reroute speech to any configured backend,
contradicting the tool's own schema header ("Voice and provider are
user-configured ... not model-selected") and leaving compact channels
unable to keep a house voice without prompt-layer appendices.

- drop provider from the model-facing TTS_SCHEMA
- stop forwarding args["provider"] in the registry handler
- keep the function argument for internal/test callers, but a value
  disagreeing with the configured tts.provider is now ignored with a
  warning (both text_to_speech_tool and _text_to_speech_single)

Fixes #90109
2026-10-05 16:42:22 -05:00
Brooklyn Nicholson
79e222d869 fix(delegation): collect a child's real result in a grace window after the stale-heartbeat verdict
When the heartbeat's stale verdict fires (frozen activity fingerprint for the
threshold cycles), await_child used to raise FuturesTimeoutError the moment the
wait ended if the worker future had not resolved — even though a child that
already wrote its final answer often lands its recorded result moments later
while its worker thread unwinds. That synthesized timeout abandoned the real
result: in an async batch the never-collected child left the unit unable to
satisfy 'all children terminal', so the batch stayed running for hours with no
completion delivered (#113222; the reporter's 7h hang, and the follow-up traces
where a task's final line sat uncollected for 23+ minutes).

Collect instead of abandoning: after the stale verdict, signal the cooperative
stop, then wait up to _STALE_RESULT_GRACE_SECONDS (2s) for the worker to
settle. The real result wins when it lands (status completed, real summary);
only a worker that never settles falls through to the existing structured
timeout with the deferred-close drain. A configured inactivity cap
(child_timeout_seconds) keeps its teeth — it is NOT a stale verdict, so the
#116001 abandonment contract is unchanged (pinned by test_delegate_liveness_timeout).
Steering stays open across the window: the success path's _merge_late_steer (or
the failure path) remains the linearization boundary, so a concurrent steer is
never lost.
2026-10-05 16:40:38 -05:00
Brooklyn Nicholson
4f91c5a462 fix(stt): opt-in 16 kHz mono normalization for command STT providers
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>
2026-10-05 16:40:15 -05:00
unsupportedpastels
e36a818033 fix(process): a completion is published once, by its owner, with its final output
Child exit, result finalization, notice publication and output consumption are
separate steps, but several callers treated an earlier one as the last:

- A duplicate _move_to_finished set _completion_event while the owning call was
  still before its notice, so a one-shot parent could exit without the follow-up.
  Only the call that moved the session out of _running releases waiters now, and
  it always does, even when its publish raises.
- _finish_exited ran mark_exited without the session lock, so a kill committed
  between its `killed` check and its writes was overwritten with exited/0.
- After a reconcile flips `exited` ahead of a reader still on its final drain,
  wait/read_log/kill no longer consume the partial output as the completion,
  and poll no longer marks it observed. poll/read_log/kill decide that under
  the same lock hold as their snapshot, so a reader finishing in between cannot
  make an earlier partial snapshot count as the whole output.
- kill_process ignored a failed systemd scope stop. It now returns an error
  with scope_stop_failed whenever the stop fails, including when the root
  exited meanwhile (that proves nothing about detached descendants); the
  session keeps its unit so a later kill retries the stop. An already-exited
  session reports scope_stop_failed too.

Process-tree termination and post-kill survivor checks move into
tools/process_registry_termination.py (ProcessTerminationMixin) to keep
process_registry.py under its size cap. The only change in the move:
_live_descendants logs the traceback of the failure it swallows.

Refs #132358. The races were found by @andrexibiza and @jonpol01 in that thread.
2026-10-05 14:35:39 -04:00
John Paul Soliva
3507653690 fix(process): a reconcile during the reader's final drain no longer truncates the output
When poll/wait/list saw the direct child exit while the reader was still
behind, the reconcile asked the reader to finish and flipped the session to
exited at once. The reader then stopped after ONE more 4 KiB chunk, so the
rest of the pipe - up to a whole pipe buffer, the job's final lines - was
never read, and the caller had already been handed a snapshot without it
(wait() also marked that snapshot consumed, suppressing the completion).

After a finish request the reader now drains what is already buffered
(zero-timeout select, capped so a grandchild that floods the pipe still
cannot pin it), and the reconcile waits briefly for the reader to publish
before falling back to marking the exit itself.

(cherry picked from commit f8266be8ccc682482f18aaeeccb4d397b5310a9b)
2026-10-05 14:35:39 -04:00
ethernet
683e845357 refactor(stt): look up whisper.cpp through pm directly
Salvage follow-up to #132976. hermes_platform/resolver/whisper.py only
wrapped pm.installed_package(), which tools/ already calls directly
elsewhere (agent-browser, cua-driver, tirith).
2026-10-05 12:37:46 -04:00
Ryan Hill
13978ae5db fix(stt): bundle whisper.cpp CPU for Windows ARM64 2026-10-05 12:37:46 -04:00
kshitijk4poor
33c9b1d779 fix(terminal): hermes chat -q never waits on a sudo prompt nobody answers
_chat_run_agent registers the CLI sudo callback in -q too and the process
exports HERMES_INTERACTIVE=1, so a sudo command without SUDO_PASSWORD /
NOPASSWD filled a panel nothing draws and waited its 45 s deadline per call
(every kanban worker). Single-query is now headless for sudo like a
delegated child: _no_sudo_user() gates the prompt (_transform_sudo_command),
the SUDO_PASSWORD tip on a headless sudo failure (_handle_sudo_failure, now
worded for any session that cannot prompt), and the "you will be prompted
again" hint (terminal_tool_result.can_reprompt). A configured SUDO_PASSWORD
and the session cache still apply; an interactive `hermes chat -q` on a TTY
never sets HERMES_SINGLE_QUERY_SESSION, so it keeps its prompt. A /dev/tty
fallthrough was rejected: it prints the prompt box onto -Q / stream-json
stdout, and on Windows msvcrt blocks 45 s on a worker's hidden console.

The protected-instruction tests now pin the cron and webhook contexts the
shared helper added, and the single_query_mode case registers a callback
that would approve, so it cannot pass via the no-callback early return.
Cases duplicating test_file_write_safety.py are dropped, and the guard
comment states the rule instead of the incident.
2026-10-05 20:08:11 +05:30
kshitijk4poor
81208783ca refactor(approval): one "no user can answer" helper for every CLI-callback prompt
`hermes chat -q`, cron and unattended platforms can all have the CLI panel
callback registered with nobody to answer it. The MCP/vault consent prompt
(#133229) and the vault unlock prompt each spelled out
single_query or cron or unattended_platform by hand; the protected-instruction
gate and the memory prompt (salvaged above) checked single-query only, so a
cron job or a webhook session could still wait the full approval timeout
there. tools.approval_context._no_user_can_answer() now answers it once and
every one of those call sites uses it.

The memory prompt's _interactive_approval_available() wrapper is folded into
that one check, and its import- and runtime-failure fallbacks (and their
parametrized test) go: _no_user_can_answer reads session env through
_session_env, which already swallows errors, and tools.approval_context
imports nothing from the memory/terminal side.
2026-10-05 20:08:11 +05:30
tachyon-r
18c54cdfb3 docs(memory): clarify headless Kanban approval context
(cherry picked from commit ac48fbb9ee5ee9eee7f65bf284451e45e6e32f05)
2026-10-05 20:08:11 +05:30
tachyon-r
f41f4276f9 fix(memory): preserve callback when context probe import fails
(cherry picked from commit 968a93daed954f765ad05c16f7cee4fc464e7e5f)
2026-10-05 20:08:11 +05:30
tachyon-r
9023a4af82 fix(memory): stage headless single-query writes
(cherry picked from commit 47468f3f37312d01ae289f20619bc753b2badcc9)
2026-10-05 20:08:11 +05:30
Daniel Green
2b525ced84 fix(approval): protected-instruction gate hangs in hermes chat -q sessions
Every write to a protected agent-instruction file (SOUL.md, AGENTS.md, ...)
from a kanban worker failed with:

  BLOCKED: ... approval prompt timed out without a user response.
  Silence is not consent.

That message says a human ignored a prompt. No prompt was ever rendered.

Root cause: `HermesCLI.__init__` registers `_approval_callback` as the
thread-local CLI approval callback before it is known whether the run will
build a prompt_toolkit Application. One-shot `hermes chat -q` runs — exactly
how the kanban dispatcher spawns every worker — never build one. The callback
still looked present, so `_request_protected_instruction_approval` took the
"a CLI human is here" branch, pushed modal state into a layout nothing
renders, and blocked on a response queue only a key binding can fill. It
burned the full approvals timeout and returned "timeout".

`_run_approval_gate` learned about this context in #86878 via
`HERMES_SINGLE_QUERY_SESSION`. This gate deliberately bypasses that gate
(one-operation approval every time, no allowlist, no persistent consent, no
--yolo) and so never got the fix. Reuse the same detector rather than adding
a second mechanism: in a single-query session, skip the unreachable modal and
fail closed immediately with the honest "no interactive user or gateway is
present" reason.

Policy is unchanged and deliberately stricter than `_run_approval_gate`:
`approvals.single_query_mode: approve` does NOT auto-approve this gate. A
protected-instruction write always needs a live human. Interactive CLI users
can still approve, and the gateway path is untouched.

Measured on the reported case: pre-fix 30s+ then a bogus "timed out";
post-fix immediate with an accurate reason.

Regression coverage (tests/tools/test_protected_instruction_cli_channel.py),
verified red before this change and green after:
- interactive approve/deny through a registered callback,
- an end-to-end round trip through the real HermesCLI._approval_callback modal,
- a single-query session never dispatches to the unrenderable modal and never
  reports "timed out",
- single_query_mode approve/deny/ask never auto-approves this gate.

Refs kanban t_9691f08d (defect surfaced by t_4e642903, 10 identical failures).

(cherry picked from commit 360d456b83250d9f348a9fc0f22e41b26ca65e15)
2026-10-05 20:08:11 +05:30
kshitijk4poor
762f419fe8 fix(approval): MCP/vault consent declines at once when no user can answer
ef1faa4cf8 (#133162) passed the agent thread's panel callback to the
classic-CLI branch of request_elicitation_consent. `hermes chat -q`
registers that callback too, so an untrusted MCP tool or a vault card
fill in a -q (or cron) turn now waited the full approval timeout for a
panel nobody can answer. tests/e2e/core/mcp_plugins/
test_mcp_stdio_conformance.py's untrusted-server turn hit its 240 s limit,
which is red on every PR that runs the e2e lane.

Decline before prompting in single-query, cron and unattended-platform
sessions: the same three no-user contexts the dangerous-command gate
resolves without a prompt, and the gateway branch of this function
already skips. Interactive chat keeps the panel.
2026-10-05 15:06:40 +05:30
Teknium
c958e1a7b9 feat(plugins): on_human_input_request / on_human_input_resolved hooks
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
2026-10-05 01:15:16 -07:00
Teknium
ef1faa4cf8 fix(approval): classic-CLI MCP/vault consent prompts reach the approval panel
request_elicitation_consent's CLI branch called prompt_dangerous_approval
without the agent thread's approval callback. Under prompt_toolkit that
fails closed before any panel renders, so an untrusted MCP tool, MCP
sampling or a vault card fill was auto-cancelled in hermes chat without
the user ever being asked (and observers saw pre/post with no human wait).
Pass the registered panel callback, as the protected-write prompt does.
2026-10-05 00:23:41 -07:00
Teknium
6ce6c1aa55 test(approval): pin CLI prompt hooks to their gateway twin's payload
Replace the four salvaged tests with two invariants: each classic-CLI
prompt (protected agent-instruction write, MCP/vault consent) fires the
exact pre/post payload its gateway branch fires, differing only in
surface; and the no-human fail-closed branch fires nothing. Drop the
protected-write try/finally to match the dangerous-command CLI site.
2026-10-05 00:23:41 -07:00