Commit Graph

2182 Commits

Author SHA1 Message Date
Teknium 94bc3194b3 feat(delegation): validate batch task quality before spawning children
Reject malformed tasks=[...] batches before any child agent is spawned:

- exact-duplicate goals (case/whitespace-normalized), error names both
  task indices
- placeholder goals: bare 'TODO', bare 'task N', unexpanded <...> or
  {...} template markers, or goals shorter than 10 chars after strip
- 1-task batches, with an error pointing the model at the single
  `goal` form instead

All checks are batch-only — the single-goal form is exempt by design
(short goals like goal="test" are valid there). Error strings are
actionable: each tells the model exactly how to fix the call.

Tool schema is unchanged (byte-stable); validation is runtime-only in
the existing batch-validation region.

Existing tests using terse batch goals ("A"/"B"/"C") updated to
realistic distinct goals per the new contract.

Inspired by: MoonshotAI/kimi-code agent-swarm.md validation rules (MIT)
2026-08-07 08:57:57 -07:00
kshitij 72c63aa586 fix(stt): close idle-unload races — strong model ref, single long-lived watcher
Review pass on the idle-unload feature found two material concurrency
bugs; both fixed here with a regression guard:

1. Unload-vs-use null deref (HIGH): _transcribe_local re-read the
   module global _local_model at the transcribe call site. An idle
   unload firing between the model load and transcribe() evaluated
   None.transcribe → AttributeError → user-visible 'Local
   transcription failed'. The window was real: the idle timer was only
   touched AFTER a successful transcription, so a voice note arriving
   exactly as the timeout expired raced the watcher directly.
   Fix: bind a strong local reference under the model lock and use it
   for the whole transcription (the watcher can null the global at any
   time; this in-flight call keeps its instance — the generator holds
   self, so no use-after-free). Also touch the idle timer at the START
   of transcription so a long in-flight transcribe can't be counted as
   idle time. The CUDA-fallback retry path gets the same treatment
   (locked global write, local ref use).

2. Watcher replacement race + response-path join (MEDIUM/HIGH): the
   old design stopped and re-started the watcher after EVERY
   transcription with an unlocked set/join(5)/clear/start sequence on
   shared globals. Two concurrent voice messages could interleave to
   leave TWO live watchers (one with a stale, shorter timeout — a
   raised unload_after_idle_seconds could still unload on the old
   value), and the join(timeout=5) sat on the user-visible response
   path (a watcher blocked on _local_model_lock during a concurrent
   multi-second model load stalls the reply up to 5s).
   Fix: single long-lived watcher under a management lock — started
   only when none is alive (per-transcription cost: one lock + one
   is_alive check), re-reads the configured timeout from config every
   cycle (config edits now apply within one 30s interval, without
   waiting for the next voice message — previously undocumented), and
   stands down without unloading when the timeout is set to 0
   mid-idle.

Tests: 17 now — idempotent start (same thread, no churn), config
re-read + stand-down-when-disabled, and the race guard
(unload firing mid-transcription must not fail the in-flight call).
The race guard is mutation-verified: reverting the fix (re-reading
the global at the call site) makes it fail with the exact NoneType
error; the fixed code passes.
2026-08-07 19:26:12 +05:30
kshitij 7b006ea6e8 feat(stt): idle unload for local whisper model
The local faster-whisper model singleton (_local_model) is loaded once
and never released — the 'base' model holds ~370 MB of RAM/VRAM for
the entire lifetime of the process, even when no voice messages arrive
for hours or days. On long-running gateway processes (especially with
local LLMs competing for the same GPU) this is wasteful.

Add a config-driven idle unload: after stt.local.unload_after_idle_seconds
(default 0 = never) of no transcription activity, a lightweight daemon
thread sets _local_model = None so the Python GC can reclaim the
ctranslate2 objects. The next voice message reloads the model
transparently (the existing lazy-load path handles it).

The watcher:
  - Checks every 30s whether idle time exceeds the configured threshold
  - Acquires _local_model_lock before unloading (prevents races with
    concurrent transcriptions that are mid-load)
  - Exits immediately if the model is already None (unloaded by another
    path, e.g. the CUDA fallback eviction)
  - Is restarted by each transcription with the current config value,
    so changing stt.local.unload_after_idle_seconds in config.yaml takes
    effect on the next voice message without a process restart

Default is 0 (never unload) — zero behavior change for existing users.
Recommended value for gateway processes: 300 (5 minutes).

15 tests: config resolution (garbage/negative/None fallbacks), unload
safety (already-None, lock acquisition), touch timestamp, watcher
lifecycle (unload after timeout, no unload within timeout, exits when
model already None, stopped on new start). Existing STT test suite
unchanged.
2026-08-07 19:26:12 +05:30
kshitij 3277eb8872 refactor(stt): fold review findings into the cloud silence trim
Three-reviewer pass (reuse / quality / efficiency) on the trim diff;
four findings folded:

1. Short-clip input gate (efficiency, HIGH): the trim previously paid
   the full ffmpeg encode before the <10%-saving discard check — every
   dense conversational voice note burned 3 subprocess spawns + a
   complete re-encode on the synchronous response path for nothing.
   New _CLOUD_TRIM_MIN_INPUT_SECONDS=12 gate: below it, savings can't
   matter (a >=10% saving is ~1s of audio, and several providers bill
   a per-request minimum anyway — Groq bills 10s minimum), so the
   whole pipeline is skipped using the duration we already probed.
   Typical 5-10s voice notes now pay 1 ffprobe (~50ms), not 3 spawns +
   encode (~0.3-1s; multi-second on small-VPS gateway hosts).

2. Shared encode profile (reuse, HIGH): the trim's ffmpeg command
   duplicated _transcode_audio_for_stt's encode byte-for-byte (same
   16kHz/mono/AAC-32k/faststart args, same subprocess.run kwargs).
   Extracted _STT_M4A_ENCODE_ARGS + _run_ffmpeg_stt_encode(ffmpeg,
   in, out, audio_filter=None); both call sites now share one owner,
   so codec/bitrate/timeout changes can't drift between the paths.

3. is_truthy_value for the enable flag (quality, MEDIUM): raw
   bool(cfg.get(...)) treated a YAML string "false" as enabled — the
   exact bug class utils.is_truthy_value (already imported, already
   used by is_stt_enabled and the xai/elevenlabs flags) exists for.

4. All-silence guard scales with keep_ms (quality, LOW): the fixed
   0.3s floor equals the default keep window, so an output consisting
   solely of one kept pause could pass as "speech"; now
   max(0.3, 2*keep_seconds).

Also: _probe_audio_duration docstring documents it as the canonical
sync seconds-probe (gateway/run.py and the Telegram adapter carry
local variants of the same ffprobe invocation).

Tests: 24 now — YAML-string-false disables; short clips skip the
encode entirely (encoder mock asserted not-called); E2E fixtures
moved past the input gate. E2E re-verified: 13.2s note -> 6.2s
(-53%), 8s clip skipped with 1 probe.
2026-08-07 19:26:04 +05:30
kshitij a683ef95d2 feat(stt): pre-upload silence trim for cloud providers
Local faster-whisper gets Silero VAD (bf8004e3a) so silence never
reaches the model. Cloud providers got no such protection: the raw
file uploads untouched, so every second of silence in a voice note is
paid for twice — upload time and per-audio-minute billing — and cloud
Whisper hallucinates junk tokens on silent stretches exactly like
local Whisper did before the VAD hardening. A 13s voice note with two
long pauses is billed as 13s of audio to transcribe ~6s of speech.

Close the gap client-side: before uploading to a built-in cloud
provider (groq/openai/mistral/xai/elevenlabs/deepinfra), collapse long
pauses with ffmpeg's silenceremove filter, keeping
stt.cloud_trim_keep_ms (default 300) of every pause so word boundaries
and natural pacing survive. Uses ffmpeg, already a dependency of this
exact path via _transcode_audio_for_stt — no new dependency.

The trim is strictly best-effort — ALL of these upload the original
untouched, transcription never fails because of the trim:
  - stt.cloud_trim_silence: false
  - ffmpeg/ffprobe missing, trim failure, or timeout
  - trimmed result ~empty (mostly-silence clip: the provider, not a
    client-side dB heuristic, decides whether it contains speech)
  - trim saves <10% (re-encoding for nothing)

Command-type and plugin providers are deliberately NOT trimmed: they
may wrap local CLIs that want the original bytes or run their own VAD.

E2E (real ffmpeg + faster-whisper): 13.2s voice note with 7s pause ->
6.2s upload (-53%); transcript of trimmed audio matches the original
on both utterances. Dense-speech and all-silence WAVs correctly fall
back to the original. 22 unit+E2E tests; STT/voice suite failures
identical to upstream/main baseline (all pre-existing).
2026-08-07 19:26:04 +05:30
kshitij b7eb97a835 fix(vision): stream image and video downloads with chunk-by-chunk size cap
_download_image() and _download_video() both used client.get() +
response.content, buffering the entire media body into memory before
checking the size cap. A server that omits Content-Length could send
an arbitrarily large payload, causing OOM.

Extract _stream_download_to_file() shared helper: streams via
client.stream() + aiter_bytes(), writes chunks to a temp file, enforces
the running byte count against the cap after each chunk, and atomically
replaces onto the destination on success. Cleans up the temp file on
failure. Uses utils.atomic_replace() for cross-device/symlink safety.

Malformed Content-Length values are now caught and ignored instead of
crashing with ValueError; the streaming cap is the authoritative guard.

Approach adapted from PR #10440 by @WuKongAI-CMU (closed as stale —
14923 commits behind, reverted 32 commits of vision_tools.py evolution
including SSRF-safe client, retry classification, and lazy imports).

Closes #10440
2026-08-07 18:50:39 +05:30
Soheil Fakour 8969ebac1c fix(secrets): redact command in process checkpoint file (#77484)
_write_checkpoint persisted s.command verbatim to ~/.hermes/processes.json.
Recovery only uses command for display/logging (the process is already
running; adoption re-validates PID + start time, never re-runs the
command), so masking is lossless.
2026-08-07 17:01:23 +05:30
Soheil Fakour 8563fe3435 fix(redact): close emission gaps - env suffix keys, control-char splits, process(list) (#77484) 2026-08-07 17:01:23 +05:30
kshitij 20e01f935b fix(voice): early-exit sliding window on match, clear barge phase in finally, fix test helper
- is_tts_echo sliding window: return True immediately when ratio >= threshold
  instead of scanning all remaining windows. Common echo case drops from
  ~4s to <1ms for long spoken text (found by /simplify-code efficiency review).
- Clear _voice_barge_phase in _voice_submit_barge_utterance finally block
  alongside _voice_barge_capture, preventing stale phase from a previous
  trip affecting a future call.
- Add _voice_last_tts_text and _voice_barge_phase to _make_voice_cli test
  helper so it matches __init__ state.
2026-08-07 14:38:02 +05:30
chelsealong 979bf0cc4a fix(voice): require minimum evidence for fragment echo matching, use char windows
Reviewer feedback on the fragment-echo fallback added in 24730b10c:
- A short playback-phase transcript (e.g. a genuine one-word "yes")
  could trivially match a same-length window of a longer spoken reply
  at ratio 1.0 and be wrongly dropped as a self-capture. The fallback
  now requires the transcript to be at least
  MIN_FRAGMENT_LENGTH_FOR_ECHO characters before it runs.
- The fallback split on whitespace, so it never engaged for
  no-whitespace languages (both transcript and spoken text collapse to
  a single "word"). Switched the sliding window to be character-based
  instead of word-based, matching the function's tokenization-independent
  contract.

Adds regression tests for both cases.
2026-08-07 14:38:02 +05:30
chelsealong b7bff6f2d7 fix(voice): catch short echoed fragments of longer multi-sentence TTS replies
is_tts_echo() compared the captured transcript against the *entire*
spoken text with a whole-string similarity ratio, which only scores high
when the two strings are close in length. But a playback-phase barge
capture is cut immediately when the trigger fires and only spans the
pre-roll buffer plus time-to-silence, so a genuine self-capture is
typically a short fragment of a longer reply, not a near-verbatim repeat
of the whole thing -- for any response longer than a clause, the
length-diluted ratio fell below threshold and the echo sailed through
ungated.

When the whole-string check misses, also slide a window sized to the
transcript's word count across the spoken text and compare against each
window, so a short fragment echoed from within a much longer
multi-sentence reply is still caught. Add regression tests for a short
fragment matched at the start and in the middle of a longer reply.
2026-08-07 14:38:02 +05:30
chelsealong d4a753ea42 fix(voice): drop playback-phase barge transcripts that echo Hermes' own TTS
The full-duplex barge-in listener added in 5081551f0 stays active during
TTS playback with no acoustic echo cancellation. On some speaker/mic
combinations, TTS bleed alone crosses the barge threshold, gets
transcribed, and is queued as the next user turn -- whose reply is then
spoken, captured, and queued again, producing an unbounded TTS -> STT ->
TTS feedback loop (#75780).

Add a fail-closed transcript-level guard: when a barge trip happens during
the playback phase, compare the captured transcript against the TTS text
Hermes just spoke (tools/voice_mode.is_tts_echo, a language-agnostic
character-level similarity ratio). A close match is dropped instead of
queued, and the mic is handed back to the normal continuous-listening
loop. Generation-phase trips (no TTS playing, so no bleed is possible)
are unaffected.
2026-08-07 14:38:02 +05:30
kshitij 99237a4444 refactor: derive teams install hint via feature_install_command(venv_pip=True)
Fold the remaining simplify-code reuse finding: teams' _install_hint()
duplicated lazy_deps' spec-fetch + quote + join (feature_install_command
already builds pip commands from LAZY_DEPS). Add a venv_pip=True variant
to feature_install_command — sys.executable -m pip targeting, correct in
every install layout and immune to PEP 668 — and shrink the teams helper
to a one-line call.  Also gives matrix and the other platforms a shared
derived hint to adopt later.  New test mutation-checked (fails when
venv_pip returns the uv form).
2026-08-07 13:28:43 +05:30
liuhao1024 66c60f81b6 fix(cron): thread per-run prompt through cronjob(action='run') (#57331)
Salvaged from PR #57342 by @liuhao1024 (with the injection-scan half
from PR #57360 by @ghedeselmabot): cronjob(action='run', prompt=...)
silently discarded the prompt argument — per-run context never
reached the spawned cron session.

The prompt is now threaded as extra_prompt through the whole chain
(cronjob run action → _try_dispatch_background_run/_execute_job_now →
_run_claimed_job → run_one_job → run_job → _build_job_prompt) and
appended to the stored prompt under a '## Run Context' header for
that single fire only — never persisted to the job definition. It
passes the same strict _scan_cron_prompt injection scan as stored
prompts before firing, and works identically on the background and
sync fallback paths.

Test fakes across tests/cron/ updated to accept the new kwargs
(sibling-test blast radius from the signature change).

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
2026-08-06 23:14:55 -07:00
Fly 7a5fe00244 fix(cron): deliver manual runs on gateway loop 2026-08-06 23:14:55 -07:00
Teknium 3671c9f188 fix: share in-flight cron dedupe between ticker and manual runs
Salvaged from PR #53395 by @izumi0uu: the fire claim's 300s TTL is
routinely outlived by real cron jobs, so claim_job_for_fire alone
cannot stop a manual cronjob(action='run') from double-firing a job
the ticker (or another manual run) is still executing.

Extract the ticker's _submit_with_guard running-set check into shared
module-level helpers (try_register_running_job / release_running_job)
and register manual runs through the same set — one dedupe owner, no
drift. Manual runs also become visible to get_running_job_ids (the
gateway shutdown drain, #60432) and mark_running_jobs_interrupted,
which previously could not see them.

The background dispatch path pre-checks the running set so a mid-run
job reports 'already running' in the tool response immediately
instead of as a delayed error completion event; the authoritative
atomic check remains in _run_claimed_job on the worker.

Co-authored-by: izumi0uu <izumi0uu@gmail.com>
2026-08-06 22:19:36 -07:00
Teknium 7ab42dda60 fix: dispatch cronjob(action='run') to the background like delegate_task
A manual cronjob run executed the job synchronously on the calling
agent's tool thread. A cron job is a full agent run that routinely
takes minutes to hours, so the parent turn sat inside ONE tool call
the whole time: uninterruptible (the interrupt flag is only checked
between loop iterations) and serial (a batch of manual runs executed
one by one). A Telegram session that kicked off dozens of new jobs
'right now' was wedged for hours ignoring every interrupt.

action='run' now rides the async-delegation rail delegate_task
background mode uses: the at-most-once claim is taken synchronously
(so paused/missing/already-firing jobs still report immediately),
the run executes on the shared daemon executor, the tool returns at
once with a delegation handle, and the job's outcome re-enters the
conversation as a type='async_delegation' completion event through
the existing completion-queue drains (CLI + gateway) — preserving
message-role alternation and the prompt cache.

Sync fallbacks preserved:
- no routable session (direct Python callers, hermes cron run)
- async delivery unsupported (hermes -z, cron child sessions,
  Kanban workers, stateless HTTP)
- dispatch pool at capacity (claim already taken — runs inline
  rather than stranding it)

The completion block reports ok/failure, delivery target, next
scheduled run, and an excerpt of the job's saved output.
2026-08-06 22:19:36 -07:00
Brooklyn Nicholson 7ad9ace2cc fix(agent): the desktop's tools reach it on remote and cloud backends too
The pane, in-app browser, and reaction tools were gated on HERMES_DESKTOP=1 —
an env var set only on backends Electron spawns itself (local and SSH). A
desktop client connected to a plain URL gateway or Hermes Cloud lost all six:
they were stripped from the schema before the model saw them, on the same
backend whose platform hint was telling it "you are chatting inside the Hermes
desktop app". open_preview, read_preview, read_terminal, close_terminal,
focus_pane, and react_to_message were all silently absent.

The client is not the host. Capability now resolves from the session's own
source, which session.create already carries:

- The six tools move into a `desktop_ui` toolset, off _HERMES_CORE_TOOLS so no
  other platform pays their schema.
- _gui_surface_toolsets(platform) folds `desktop_ui` (and the existing
  `project` tools) into the GUI gateway's resolution when the session's
  platform is the desktop app — the same answer on every topology.
- check_fn drops the env probe. It kept the one thing that is genuinely a
  per-process/user fact: react_to_message's display.message_reactions opt-in,
  which the desktop mirrors onto whichever gateway it is connected to.

react_to_message was doubly broken: it read that toggle behind the env gate, so
even a local-backend user's Settings toggle could not reach a remote session.

The embedded terminal pane keeps working correctly the other way round: it runs
`hermes --tui` against a desktop-spawned backend, and a tui-sourced session
gets no GUI tools even though HERMES_DESKTOP=1 is set on that process.
2026-08-06 19:35:47 -06:00
SmokeDev 9d4ef04ed0 fix(delegation): bind steering to session generation 2026-08-06 09:40:27 -07:00
SmokeDev a94ebf5f5e fix(delegation): harden steer lifecycle ownership 2026-08-06 09:40:27 -07:00
SmokeDev 60e1f7517c fix(delegation): surface a child's undelivered steer instead of dropping it
The turn finalizer already hands back steer text that queued after the
final tool batch — result["pending_steer"], with the comment "hand it
back to the caller so it can be delivered as the next user turn instead
of being silently lost." Every interactive surface honors that contract
(cli.py, gateway/run.py, tui_gateway/server.py all requeue it). The
delegation layer doesn't: _run_single_child never reads it, so a steer
queued into a delegated child that finishes first vanishes with no trace
in the completion entry. There is also no sanctioned sender: the registry
has interrupt_subagent() but no redirection-side mirror, and session.steer
cannot reach children (lazy watch sessions have agent=None, so it 4010s).

Complete the contract for delegated children — both halves:

- steer_subagent(subagent_id, text): redirection-side mirror of
  interrupt_subagent(). Resolves the live child in _active_subagents and
  queues text via AIAgent.steer(). True means queued, not delivered.
- missed_steer retention: when the child's result carries pending_steer,
  _run_single_child names it on the completion entry (missed_steer field
  plus a summary note) so the parent can re-issue the guidance instead of
  trusting it landed. This is what makes adding a sender safe: without it
  the finish-before-drain race silently loses the text — the exact loss
  the finalizer contract exists to prevent.
- subagent.steer gateway RPC beside subagent.interrupt so programmatic
  hosts (dashboard, voice layers, ACP bridges) get an in-tree caller;
  catalogued in programmatic-integration.md.
- docs: "Steering a Running Subagent" section in delegation.md covering
  the queued-vs-delivered semantics.

Tests: registry-level steer coverage (delivery, unknown id, empty text,
dead record, raising agent), the finish-before-drain race retaining
missed_steer, and the RPC contract (4000/4002 validation, queued and
rejected envelopes).
2026-08-06 09:40:27 -07:00
Adolanium ffdbc883ee fix(read_extract): cap anydoc input size before conversion
The anydoc path from #79781 passed every covered file straight to
to_markdown with no pre-check. anydoc loads the whole document through
its Rust core and the read_file char budget only applies after
conversion, so one large PDF or deck could pin a tool turn and spike
RAM.

_extract_anydoc now rejects inputs over MAX_ANYDOC_BYTES (50 MB) with
ExtractionError before calling the converter, which routes them to the
existing read_file fallthrough instead of converting. No timeout is
added: the conversion is a synchronous Rust call that cannot be
cancelled from Python, so a thread-based deadline would bound the wait
but leave the RAM burn running in the background.
2026-08-05 22:02:42 -07:00
Adolanium 997a913a58 fix(read_extract): retry anydoc init after failure instead of sticky disable
The first _anydoc() load cached None on any failure (network blip,
missing wheel, pip race), so one bad first try disabled document
extraction for the rest of the process. Long-lived gateway and desktop
workers never recovered.

Failed loads now cool down for ANYDOC_RETRY_SECONDS and retry instead
of sticking, and a lock serializes first use so parallel readers cannot
double-install or race a failure into the cache. Successful loads are
still cached for the process lifetime.
2026-08-05 21:59:34 -07:00
Teknium b2598b41e1 feat(read_file): widen document extraction to PDF/legacy Office/ODF/RTF/EPUB via optional anydoc
read_file's auto-extraction covered only the stdlib trio (.ipynb/.docx/
.xlsx). firecrawl-anydoc (MIT, Rust core, imports as `anydoc`) converts
Word, PowerPoint, Excel — including legacy .doc/.ppt/.xls — OpenDocument,
RTF, EPUB, and PDF to clean Markdown through one shared document model.

Wiring follows the footprint ladder: no new tool, no hard dependency.
- tools/read_extract.py gains an ANYDOC_EXTENSIONS set that is active
  only when the converter imports; the stdlib extractors remain
  authoritative for their three formats so behavior is identical with
  or without the package.
- tools/lazy_deps.py adds tool.doc_extract (firecrawl-anydoc==0.1.6),
  installed on first read of such a file with prompt=False so read_file
  can never block. Lazy-only for now: the package's first release was
  2026-08-04, inside uv's 14-day exclude-newer quarantine, so the
  mirrored pyproject extra lands after it clears.
- Any anydoc ConvertError maps to ExtractionError, falling back to the
  existing path/binary handling instead of erroring the tool.

Tests: real-binding suite skips cleanly when the wheel is absent
(verified: 15 passed/3 skipped without it, 18 passed with it), plus an
absent-dep contract class that pins the fallback regardless of local
install state.
2026-08-05 17:07:47 -07:00
kshitij 9baf92b7f3 test(search): update grep command mirrors to -rnHE for fidelity with production 2026-08-06 05:31:17 +05:30
Kevin Yin 7c6f9affd7 fix(file): align grep fallback regex behavior 2026-08-06 05:31:17 +05:30
Jeffrey Quesnelle 0531aad55d Merge pull request #68883 from afourniernv/feat/hermes-relay-skill-metrics
feat(observability): aggregate bounded skill metrics
2026-08-05 13:20:57 -04:00
brooklyn! 64646dda56 Hermes can read the in-app browser (#79482)
* feat(agent): read_preview — the desktop-gated tool that reads the in-app browser

The agent could open the preview pane (open_preview) and read the embedded
terminal (read_terminal), but the browser it had just opened was a black box —
'what does this page say?' had no answer. read_preview mirrors read_terminal
end to end: HERMES_DESKTOP-gated via check_fn (zero schema footprint outside
the GUI), dispatched through the same agent callback pattern, windowed with
start/count so a long page pages instead of flooding context.

* feat(gateway): preview.read blocking bridge

Same lifecycle as terminal.read: the tool blocks on preview.read.request, the
renderer answers preview.read.respond (allow_expired — a slow page extraction
losing the 45s race must not surface a raw 4009), and a timeout emits
preview.read.expire so late answers resolve quietly.

* feat(desktop): the renderer serializes the active preview tab for the agent

preview-reader.ts is the preview analog of the terminal's buffer registry: the
URL pane registers a page reader (webview executeJavaScript → title + visible
innerText) keyed by tab id; readActivePreview resolves the ACTIVE tab, windows
the text (24k cap per read), and answers file/artifact tabs with identity plus
a note pointing at the tool that reads that content directly. The gateway
event handler answers preview.read.request beside terminal.read.request.
2026-08-05 16:35:00 +00:00
Brooklyn Nicholson 60808dcf72 fix(wake): auto capture keeps the backend mic when one exists
With capture:auto the desktop always preferred client streaming, so a local
desktop with a working backend mic silently switched from PortAudio to
getUserMedia default-device — dropping wake_word.input_device selection
(#74363). A ready backend input now wins under auto; client capture is the
fallback for a preferring surface on a mic-less backend, and capture:client
still forces streaming.

Also removes the dead auto branch (both arms returned local) and lets the
client-feed test skip cleanly when numpy is absent.
2026-08-05 10:06:49 -06:00
Andrew 105fbf6b7d feat(wake): client-capture wake word for remote desktop
Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.

- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)
2026-08-05 10:03:48 -06:00
xxxigm d55bc063f1 fix(delegation): keep subagents alive during slow model waits
Top-level delegate_task runs in the background, and the 450s progress-stall
monitor only sees api_call_count / tool / last_activity_ts. Subagents use
non-streaming direct_api_call, which previously touched activity once and then
went silent — so a healthy local GGUF / long-prefill wait looked frozen and
was interrupted around ~450s as "Operation interrupted: waiting for model
response", even when child_timeout_seconds was raised. Refresh activity while
the inline request is open, and treat last_activity_ts advances as sync
heartbeat progress too.
2026-08-05 14:00:25 +05:30
Alex Fournier d20debd446 Merge updated tool metrics into skill metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
2026-08-04 09:54:43 -07:00
Jeffrey Quesnelle daf67f2e59 Merge branch 'main' into feat/hermes-relay-tool-metrics 2026-08-04 12:43:38 -04:00
kshitijk4poor 8c19e29259 refactor(file-ops): fold simplify-pass findings
- write_file: encode content once, share bytes between bytes_written and
  the sha256 verification (drops a second full-content encode per write)
- patch_parser: replace the except-TypeError retry around
  write_file(pre_content=...) with signature-based feature detection so a
  TypeError raised inside a capable implementation propagates instead of
  triggering a duplicate write; tests for both duck-typing contracts
- tests: real-ops V4A BOM round-trip + _file_has_bom disk-probe guard
  (the teknium1-review regression previously only covered by a fake)
- comment: document dirs_created's long-standing "parent ensured" meaning
2026-08-04 14:34:24 +05:30
阿泥豆 eb78ab235f fix(file-ops): decouple BOM detection from pre_content, add V4A backward compat
Bug 1 (UTF-8 BOM loss on V4A UPDATE):
_file_has_bom() trusted pre_content for BOM detection, but the most
common pre_content provider — read_file_raw() — deliberately strips
BOMs so the agent never sees U+FEFF glyphs.  Passing BOM-stripped
content through pre_content caused a false-negative: the method
returned False and write_file() silently removed the marker on rewrite.

Fix: _file_has_bom() now always probes the first 3 bytes on disk
(head -c 3), ignoring pre_content for BOM purposes.  pre_content is
still used by two other consumers — line-ending detection and lint/LSP
delta computation — neither of which is affected by BOM stripping.

Bug 2 (backward compatibility):
_apply_update() called write_file(path, content, pre_content=...) as a
keyword argument.  Duck-typed file_ops implementations that only
implement the two-argument write_file(path, content) contract would
raise TypeError.

Fix: wrap the call in try/except TypeError, falling back to the
two-argument form when the keyword is not accepted.

Also declare tomli in pyproject.toml (pre-existing conditional import
for pre-3.11 Python, caught by the pre-commit dep scan after staging
file_operations.py).

Tests:
Add TestV4ABomRoundTrip with two cases:
  - UPDATE on BOM-bearing file preserves the marker
  - UPDATE on plain file does not inject a BOM

Addresses teknium1 review on PR #55661.
2026-08-04 14:34:24 +05:30
阿泥豆 cb3e8e9fb1 perf(file-ops): eliminate redundant subprocess calls in write_file and V4A patch path
write_file currently spawns up to 6 subprocesses per call:
  1. mkdir -p (separate call before atomic write)
  2. cat (to read pre-content for lint/BOM/line-ending detection)
  3. _atomic_write (mktemp + write + mv — the essential one)
  4. wc -c (to measure bytes written)
  5. _check_lint_delta (post-write lint — also essential)
  6. LSP snapshot (also essential)

This PR removes three of them without changing any observable behavior:

1. Fold mkdir -p into _atomic_write shell script (−1 subprocess/write)
   The atomic write script already runs a single shell; adding mkdir -p
   to it costs zero extra processes.

2. Add optional pre_content parameter to write_file (−1 subprocess/patch)
   patch_replace and V4A _apply_update already read the file for fuzzy
   matching. Passing that content as pre_content skips the redundant cat
   inside write_file. Fully backward-compatible: callers that don't pass
   pre_content still read from disk as before.

3. Replace wc -c with len(content.encode('utf-8')) (−1 subprocess/write)
   We already have the content in memory; encoding it to get the byte count
   is equivalent to wc -c for UTF-8 text.

4. Remove redundant _check_lint loop in apply_v4a_operations (−N subprocesses/V4A)
   write_file already runs _check_lint_delta internally. The old code ran a
   bare _check_lint(f) loop over all modified files — a re-read + re-lint
   without post_content context. Now lint results propagate from write_file
   via a four-tuple return, zeroing out the extra subprocesses.

Net effect:
  - write_file: 6 → 3 subprocesses per call (new files)
  - patch_replace: 6 → 5 subprocesses per call (pre_content skips cat)
  - V4A multi-file patches: saves 1 subprocess per modified file
  - A typical 4-file V4A patch drops from ~28 to ~16 subprocess calls
2026-08-04 14:34:24 +05:30
EndeavorYen 952d86b797 fix(file-sync): serialize concurrent sync cycles 2026-08-03 22:53:32 +05:30
Carbon 48e12a06fa perf(tools): shrink lazy tool catalog overhead 2026-08-03 19:11:30 +05:30
Jakub Wolniewicz ffb54305c4 perf(session-search): project fields before enrichment 2026-08-03 17:50:58 +05:30
PRATHAMESH75 fe6330de03 fix(stt): thread confidence thresholds into faster-whisper's own gate (#74178)
build_local_transcribe_kwargs read stt.local.no_speech_prob_threshold /
stt.local.logprob_threshold only for Hermes' post-filter
(_is_hallucinated_segment). faster-whisper's model.transcribe() never
received them, so its internal defaults (no_speech_threshold=0.6,
log_prob_threshold=-1.0) always applied and silently dropped
low-confidence segments before they reached the post-filter — making
those config knobs dead for the first gate.

Non-English speech decodes at a lower avg_logprob, so the English-tuned
defaults discard whole utterances (empty transcript despite correct
capture and language detection). Map the same config values through to
model.transcribe() so both gates stay in sync and the knobs work.
Defaults are unchanged, so behavior is identical unless a user tunes them.

Fixes #74178
2026-08-03 14:30:05 +05:30
kshitij ebf967ff2c polish(mcp): simplify-pass folds on the lazy-startup salvage
Five review findings folded:
- schema cache writes via utils.atomic_json_write (fsync; was bare
  tmp+replace), file moved to cache/mcp_schema_cache.json with 0o600
  (sibling precedent: registry discovery cache)
- phantom-tool reconciliation: after a lazy server's first-use connect,
  cached tools the live server no longer offers are deregistered (were
  permanent registry ghosts burning circuit-breaker strikes on every
  'Unknown tool' round-trip); stale fingerprint logged
- cache-load path now runs _scan_mcp_description like the eager path
  (cache file is user-writable JSON; defense-in-depth)
- write-through skips the disk rewrite when the entry is unchanged
  (a flapping stdio server was rewriting byte-identical JSON per
  revival)
- _lazy_server_fingerprints no longer write-only dead state (consumed
  by the reconciliation logging)

444 mcp tests green (440 pre-fold + 4 new guards); phantom-dereg and
write-skip mutation-checked.
2026-08-03 14:24:37 +05:30
kshitij 1d5ecad568 feat(mcp): lazy server startup from schema cache (design from #56832)
Wires the fingerprint-keyed schema cache (previous commit, @Vansh5632's
design from #56832) into the startup path, re-derived onto main's
current connect machinery:

- register_mcp_servers: servers with mcp_servers.<name>.lazy=true whose
  config fingerprint matches a valid cache entry register tools from
  cache WITHOUT spawning; miss/stale falls back to eager connect.
- First tool use routes through _ensure_lazy_server_connected, which
  composes with the connect cooldown (#50394) and _server_connecting
  dedup rather than duplicating the connect path.
- resource/prompt utility handlers (list_resources/get_prompt) also
  connect-on-first-use — closes the gap flagged in the original
  sweeper review.
- Write-through: a live connect refreshes the cache entry.

Config gate is per-server, default OFF, matching the
idle_timeout_seconds key pattern. 24 lazy/cache tests + 440 mcp-wide
green; mutation-checked (cache-read disabled -> registration test
fails; connect bypassed -> 3 first-use tests fail).
2026-08-03 14:24:37 +05:30
liuhao1024 f07f47fe7d fix(lazy-deps): skip the install ladder on package-manager installs
Salvage of #48637 (Fixes #48628). On a NixOS-style install the venv's
site-packages lives in the read-only store, so ensure()'s
uv -> pip -> ensurepip ladder spends ~15s bootstrapping ensurepip only
to fail against a target it can never write. Fail fast with an
actionable message pointing at the system package manager.

Retargeted onto current main (the PR's base predates the durable-target
subsystem by ~8.1K commits) with two corrections to the original:

- Gate on _lazy_install_target() is None. The container deployment sets
  HERMES_MANAGED=true AND HERMES_LAZY_INSTALL_TARGET (a writable
  volume); the original guard would have blocked installs that path
  legitimately satisfies, breaking the NixOS-container mode.
- Reason string starts with 'unsupported ' because
  refresh_active_features classifies FeatureUnavailable by that prefix;
  the original wording made 'hermes update' report a hard failure
  instead of a skip.

Placed after _unsupported_feature_reason so a platform-specific reason
(more actionable) wins, and so ensure() agrees with
refresh_active_features, which pre-checks that same function.
2026-08-03 14:09:46 +05:30
f1aggo_macair 911d380296 fix(tools): allocate snapshot temp paths with mktemp instead of $BASHPID
Extracted from #54314 (@flag0x369), re-derived onto current main: macOS
ships bash 3.2 as /bin/bash, which lacks $BASHPID entirely — the
variable expands to empty string, collapsing every concurrent writer's
'unique' temp path onto the same file (torn snapshot writes under
concurrency). mktemp allocates per-writer unique paths portably.
Live-verified: /bin/bash -c 'echo $BASHPID' prints empty on this box.
2026-08-03 13:47:29 +05:30
f1aggo_macair 0125281609 fix(tools): allow Unicode letters in workdir validation
The workdir allowlist regex was ASCII-only, so perfectly normal
non-ASCII workdirs (Chinese Obsidian vault paths, accented dirnames)
were rejected with 'disallowed character'. Replace the regex with a
per-character check that accepts Unicode letters/digits (str.isalnum)
plus the same safe ASCII punctuation set, while still rejecting shell
metacharacters, control characters (newlines/tabs), and NUL.

Salvaged from PR #54314.

Co-authored-by: kshitij <82637225+kshitijk4poor@users.noreply.github.com>
2026-08-03 13:47:29 +05:30
Xue-1997 72e8e2983a fix(session-search): strip ANSI from recalled messages
Recalled session messages can carry raw ANSI escape sequences (e.g.
archived terminal output), which then re-enter the model's context.
Strip them in _shape_message before content is truncated/returned,
reusing tools.ansi_strip.strip_ansi.

Re-applied onto current main (the original hunk predates the
max_content_len truncation in _shape_message; stripping happens on the
raw content before truncation so escape bytes never count against the
budget). Extracted from #40276.
2026-08-03 13:47:03 +05:30
Teknium d127fb2197 fix(approval): stop treating newlines inside quoted arguments as command starts
A raw newline in the _CMDPOS start-position class made ANY multi-line
quoted argument look like a command boundary, so hermes send message
bodies, multi-line git commit -m messages, and heredoc text that merely
mentioned dangerous command names tripped the unconditional hardline
blocklist and could not run at all.

Mask newlines inside single/double quotes (detection-only, mirroring the
quote tracking in _iter_shell_command_starts) before building detection
variants. Real threats keep blocking: unquoted newlines stay command
separators, command substitutions inside quotes still anchor, and
_mark_command_starts still re-inserts newlines at genuine quote-aware
command starts. Masking runs on the RAW command before normalization,
which strips escapes and would otherwise corrupt quote state.

Regression tests cover both directions: multi-line quoted data passes
(hermes send, git commit -m, heredocs); bare/chained/substituted
shutdown-class and rm-floor commands still block.
2026-08-02 23:11:56 -07:00
Teknium 4be0d56023 perf(tools): compact delegate_task description by deduping against param schema
The top-level delegate_task description repeated content the model already
receives through parameter descriptions: the concurrency limit (tasks param),
the full nesting clause (role param), context-passing guidance (goal/context
params), and background semantics (background param). Every API call paid for
the duplication (~4,000 chars).

The description now carries only what exists nowhere else in the schema:
use/don't-use routing (execute_code, cronjob), the no-poll rule, the
non-durability warning, the self-report verification contract with concrete
verbs, the language-passing example, the leaf blocked-tool list, and model
inheritance. 3,963 -> 1,704 chars (~570 tokens saved per API call), and the
top-level text is now static (dynamic limits flow only through the two param
descriptions, which are already rebuilt per get_definitions() call).

A/B benchmark across 4 models (gpt-4o, gpt-4o-mini, claude-haiku-4.5,
llama-3.3-70b) showed the naive compaction in PR #72813 regressed weaker
models on exactly the passages it cut (side-effect verification 8/8->0/8 on
gpt-4o-mini; language passing 3/3->0/3 on haiku-4.5). This version keeps
those benchmark-sensitive hooks verbatim.

Tests pin the contracts at keyword level (not prose-literal) plus a size
ceiling, and verify dynamic limits still reach the model via the tasks/role
param descriptions.

Refs #72737, supersedes the delegate_task half of PR #72813.
2026-08-02 22:44:58 -07:00
Mahdi Hedhli 75901a295d perf(tts): pipeline sync per-sentence synthesis with playback
The universal sync fallback in stream_tts_to_speaker ran strictly serially
per sentence — synthesize, play, and only then start synthesizing the next
sentence — so every sentence boundary added a full synthesis-time of dead
air. Chunked streamers (elevenlabs/openai/gemini/xai) already avoid this;
every other provider (edge, piper, plugin providers) paid it on each reply
in voice mode and the wake-word loop.

_SyncSentencePipeline overlaps the two: one single-threaded synthesis
worker (sentences stay FIFO; providers never see concurrent calls from
this loop — same effective concurrency as before) feeds one playback
worker through a small bounded queue, so sentence n+1 synthesizes while
sentence n plays. Lookahead is bounded (backpressure + at most a couple of
temp files), stop_event short-circuits both stages, synthesis failures are
isolated per sentence, temp files are always unlinked, and the finally
block flushes the pipeline BEFORE tts_done_event fires so continuous voice
mode never reopens the mic over its own voice. synthesize/play are
resolved late so existing monkeypatch-based tests work unchanged.

Measured with a real local model provider (OmniVoice plugin, Apple
Silicon), same 3-sentence reply, playback simulated at the produced clips'
true durations, best-of-2 interleaved runs under identical load:

                     serial   pipelined
  time to first word  10.8s        4.4s
  mid-reply dead air  11.2s        1.8s   (second gap: 0.03s)
  full reply wall     33.2s       17.0s

Tests: 4 new (timestamp-proven overlap, order + per-sentence failure
isolation, stop skips queued playback, temp-file hygiene); the existing
sync-fallback and display-callback tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:25:15 +05:30
Teknium aac74be2f1 fix(approval): classify CLI/TUI approval timeouts separately from explicit denials
When an approval prompt expired without a response, every CLI-side path
collapsed the timeout into the same 'deny' choice as an explicit user
refusal, so the agent was told the user denied the action when the user
simply never answered. The gateway wait already distinguished the two
('timed out without user response... Silence is not consent.'); this
brings the CLI/TUI/ACP surfaces to parity.

- prompt_dangerous_approval(): input()-path expiry now returns a distinct
  'timeout' choice (still fail-closed).
- cli.py _approval_callback + hermes_cli/callbacks.py approval_callback:
  deadline expiry returns 'timeout' instead of 'deny'.
- check_all_command_guards / _run_approval_gate CLI tails: 'timeout' maps
  to outcome='timeout' with a 'timed out without user response... Silence
  is not consent.' BLOCKED message (matching the gateway wording);
  explicit deny keeps outcome='denied' and gains user_consent=False for
  shape parity.
- computer_use: 'timeout' verdict threads through the CLI adapter and
  yields a 'prompt timed out — the user did not respond' error instead of
  'denied by user'.
- ACP permissions bridge: FutureTimeout returns 'timeout' (other failures
  still 'deny'); elicitation maps 'timeout' to 'cancel' like the gateway's
  unresolved outcome; codex wire mapping documents deny/timeout→decline.
- write_approval already treats unknown choices as 'stage, not drop', so
  a timeout now stages the memory write instead of silently refusing it.

Every timeout path remains fail-closed — the action never runs; only the
classification reported to the agent changes.
2026-08-02 20:21:59 -07:00