Consolidates 5 follow-up PRs onto the a2a-work branch:
1. Reply-capture fix (#56437): adapter.send() now only resolves the
blocked RPC Future when metadata['notify'] is True (the gateway's
final-reply marker). Interim sends no longer short-circuit the
response. Also accepts **kwargs in connect() for reconnect compat.
2. Slash command passthrough (#53743): wrap_inbound() passes /-prefixed
text through unwrapped so the gateway command processor sees it.
Fixes /sethome deadlock during A2A onboarding. Documented security
trade-off (bearer auth at network layer compensates).
3. Routable URL in Agent Card (#53736): _build_card() now derives URL
from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host.
Fixes k8s bug where Agent Card advertised 0.0.0.0.
4. contextId multi-turn memory (#53756): _handle_inbound_task() now
checks top-level params.contextId first (A2A spec), falls back to
params.message.contextId (legacy). Outbound a2a_call also sends
contextId at both top-level and inside message.
5. Type checker fixes (#53759): TypedDict for _SCHEMAS, _FunctionSchema,
_ToolSchema. Removes str() band-aid casts.
All 45 tests pass including new tests for each fix.
Zero core files modified — only plugins/platforms/a2a/ and tests/.
Credits: @davidrobertson (#56437), @knoal (#53736, #53743, #53756,
#53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug
report), @shivasymbl (#45996 userContext OBO).
Live Tier-3 testing (CLI agent -> a2a tools -> live peer gateway -> model)
surfaced two bugs the kwarg-style unit tests masked:
1. registry.dispatch calls handlers as handler(args, **kwargs) — args is the
whole dict positional. The handlers used keyword params (url=, agent=), so
the dict bound to the first param and .strip() raised
'dict object has no attribute strip'. Rewrote all three handlers to take
args: dict (matching the spotify/google_meet convention). Added a
registry-dispatch regression test that exercises the real call path the
direct-kwarg tests never hit.
2. The model repeatedly reached for agent_name= instead of agent= (6 retries
before success). Accept agent_name/name and message/text/task aliases so a
reasonable guess succeeds first try.
Verified live: client agent discovers the peer's Agent Card, calls it, and
gets the reply back (PONG round-trip confirmed on both client audit log and
peer conversation log). 39 plugin tests pass.
The a2a client tools are registered unconditionally by the plugin, but a
newly-registered plugin toolset defaults to ENABLED for every platform until
the user has seen it in 'hermes tools'. That force-injected 'a2a' into every
agent's enabled_toolsets, leaking 3 tool schemas to all users and breaking
tests that assert exact toolset membership
(test_api_server_toolset::test_create_agent_respects_config_override).
Add 'a2a' to _DEFAULT_OFF_TOOLSETS so it stays opt-in (user enables via
'hermes tools'), matching the spotify precedent. The inbound platform
adapter is already opt-in (only instantiated when the a2a platform is
enabled); this aligns the outbound client tools with the same posture.
Single platform-adapter plugin under plugins/platforms/a2a/ — zero core
edits — that supersedes the entire A2A PR/issue cluster. Built on the
ctx.register_platform + ctx.register_tool surface the codebase now exposes.
Outbound (a2a toolset): a2a_discover / a2a_call / a2a_list let the agent
call any A2A-compliant peer over JSON-RPC message/send. Inbound (platform
adapter): a stdlib http.server serves an Agent Card at
/.well-known/agent.json and routes incoming tasks into the agent's LIVE
gateway session (the #11025 insight) — same agent, full memory — returning
the reply over A2A.
Security on by default: no bearer token => 127.0.0.1-only bind; constant-
time bearer auth; inbound prompt-injection filtering + untrusted-peer
framing; outbound credential redaction; append-only audit log; per-context
conversation persistence outside the compaction pipeline.
Stdlib only (no a2a-sdk). 37 tests incl. a live HTTP round-trip
(card + message/send + reply) and a bearer-auth 401 path.
Production mining (state.db, 400k-msg window): 60.2% of 104k terminal
calls carry a defensive 'cd X && ' prefix (~925k tokens of pure prefix)
and 2,462 failed calls led with cd — the model cannot see cwd state, so
it re-asserts it on every call and runs pwd/ls diagnostics after
directory changes.
The result dict now includes a 'cwd' field whenever the session cwd
after the command differs from the cwd it started in (cd, pushd,
chained cd). Stable-cwd commands are unchanged (no field, no noise).
Per-command workdir overrides stay transient by contract and never
echo. Schema note added so models learn to trust session cwd instead
of prefixing. Pattern borrowed from crush's <cwd> injection.
realpath comparison avoids false echoes through symlinks; the echo is
wrapped defensively so a backend without .cwd can never break the
result path.
The #1 patch failure class in production (state.db mining, 250k-window)
is a re-send of an edit that already landed: 'old_string and new_string
are identical' (299 occurrences) plus a share of hunk-not-found errors
where the new text is already in the file. These errored, sending
models into re-read/re-patch loops.
New tools/fuzzy_match.is_already_applied(content, old, new) — a
conservative check requiring (1) non-trivial new_string (>=8 chars),
(2) EXACT presence of new_string, (3) old_string gone (unless
identical). Wired into three sites:
- patch_replace (replace mode): returns success + no_change: true +
an explicit note instead of the identical-strings / no-match error.
- V4A validation phase: an already-applied hunk validates as a no-op
so multi-hunk patches no longer fail wholesale when one hunk landed
in a prior call.
- V4A apply phase: mirrors the same skip so the two phases agree.
Genuine no-matches (new text absent) and half-applied renames (old
text still present) keep their error behavior — covered by tests.
Production mining (state.db, 28.5k read_file calls in the recent
window) shows 74.3% of reads truncated — nearly all by the 500-line
default, not the char budget (22,443 line-limit vs 13 char-budget
truncations). That churn produced 12,229 redundant re-reads, and after
a truncated result the most common next move was fleeing to terminal
cat/sed (4,608 times) — the pagination contract was not trusted.
Median truncated file is 2,422 total lines, so a 2000-line default
makes 44% of today's truncated first-reads complete in one call while
the unchanged ~100K-char budget still caps worst-case result size
(same ceiling as before: 500 lines x 2000-char line cap = the same
100K). Schema max was already 2000.
Touchpoints: DEFAULT_READ_LIMIT + both read_file signatures
(file_operations.py), read_file_tool + schema text (file_tools.py),
execute_code sandbox stub docs (code_execution_tool.py), 3 tests
pinning the old default.
Builds on the adapter list_channels() hook (cherry-picked from #43545 by
@Guoen0):
- plugins/platforms/simplex: implement list_channels() — enumerates
contacts (/contacts) and groups (/groups) over the live daemon
WebSocket into the channel directory. Returns None when the WS is
down so the directory falls back to session discovery instead of
wiping known targets.
- hermes send --list: merge configured-but-undiscovered platforms into
the listing. Previously a platform configured only via env (e.g. a
fresh SimpleX setup used for outbound sends) was silently omitted,
leaving users guessing at platform names.
- format_directory_for_display(): accept an explicit platforms view and
render empty platforms with a targeting hint instead of hiding them.
- docs: simplex hermes-send section.
Reported by Fedpostoffice on Discord (simplex missing from
hermes send --list; guessed platform names simplex-chat/simplex-relay).
When a command exits non-zero, scan the first 4KB of output for
well-known failure shapes and attach one short, actionable recovery
hint to the tool result ('hint' field):
- gh 'Unknown JSON field' (9.2k occurrences in a 250k-call window)
- git merge conflicts (1.2k) — stop verbatim retries
- command not found (1.0k), incl. python->python3 and pip->pip3
- ModuleNotFoundError (739) — venv activation guidance
- 'already exists' (633), gh rate limits (133), permission denied
- exit-code-only tier: 124 timeout, 126 not-executable, 137 SIGKILL
Hints are suppressed when the existing exit_code_meaning tier already
explains the code (grep=1 etc). Pattern order = production frequency
from state.db mining; first match wins; pure function, no I/O.
The delivery worker is a daemon thread, so a short-lived process
(hermes chat -q, a cron session) could exit right after firing
on_session_end — silently dropping the headline event. Register a
bounded atexit flush (5s) when the worker starts: a dead endpoint can
delay exit slightly, never hang it.
Live-verified: hermes chat -q now delivers on_session_start,
post_tool_call, and on_session_end to a real receiver; regression test
runs a subprocess that exits without flushing (sabotage-verified).
- delivery_id is now generated once per firing and used for both the
X-Hermes-Delivery header and the signed body's delivery_id field —
previously they were two different uuid4s, breaking receiver-side
dedupe as documented.
- 3xx responses are no longer followed: urllib's default redirect
handler converts a redirected POST into a body-less GET, silently
dropping the signed payload. Redirects now log a misconfiguration
warning and count as delivery failure (no retry).
- Docs: receiver-side replay-protection guidance (dedupe on
delivery_id, timestamp freshness window) + redirect semantics.
- Tests: 5xx retry count, redirect-not-followed (sabotage-verified),
header/body delivery_id equality.
The inverse of the inbound webhook platform: hooks.outbound in
config.yaml lists HTTP targets + the plugin-hook events they subscribe
to (on_session_end, subagent_stop, post_tool_call, ...). Each firing
POSTs a JSON payload (same top-level shape as shell hooks' stdin wire)
signed GitHub-style with HMAC-SHA256 (X-Hermes-Signature-256).
Rides the existing hook bus — notify-only callbacks registered on the
plugin manager at the same CLI/gateway/main entry points as shell
hooks. Delivery is fire-and-forget via a bounded queue + single daemon
worker thread, so a dead endpoint can never stall a tool call. Bounded
retries (5xx/conn errors once; 4xx never). secret_env preferred over
inline secret. HERMES_SAFE_MODE skips registration. hermes hooks list
shows outbound targets with signed/UNSIGNED status.
Zero new model tools, zero new subsystems.
Answers and written deliverables that rest on retrieved information now get
inline numbered citations plus a mechanically-rendered Sources list, with a
persistent ledger that makes a hallucinated citation detectable.
- skills/research/grounded-citations/scripts/sources.py: stdlib citation
ledger (add/ingest/list/render/verify) at
$HERMES_HOME/cache/citations/ledger.json, profile-aware, O_EXCL-locked so
parallel subagents sharing a ledger can't collide on ids
- SKILL.md: cite-while-drafting procedure, register-at-retrieval rule,
pitfalls, verification gate
- references/citation-formats.md: per-target placement (markdown, LaTeX/PDF,
docx, pptx, xlsx, wiki, BibTeX handoff to research-paper-writing)
- references/grounding-rationale.md: why numbered ids (ALCE 2305.14627,
WebGPT 2112.09332, Perplexity marker conventions), and how this relates to
the in-process registry in PR #44833
- tests/skills/test_grounded_citations_skill.py: 30 tests
The LRU-cap test in tests/tui_gateway/test_protocol.py stubs
_close_session_by_id with a two-arg lambda; the reaper now passes a
revalidation predicate. Widen the stub signature.
The Discord semantic thread-rename lane resolved the target thread from
`_relay_auto_thread_info`, which read a single-slot-per-parent-chat cache
(`adapter._auto_thread_by_chat[chat_id]`, populated from connector
SendResult feedback). When two auto-threads spawned from the SAME parent
channel, the second send overwrote the first's slot and the title turn's
read raced the write — so only the FIRST thread in a channel ever got its
semantic rename. Staging repro 2026-08-02: message A's thread renamed to
"A Hundred Word Sword Story", sibling message B's thread stayed stuck at
the raw first-words name.
The connector now stamps `prospective_thread_id` on the inbound (the anchor
message id, which is the id of the thread it will auto-create) — shipped for
per-thread session keying. Reuse it here: it is deterministic and
per-message, so it names the EXACT thread even when several auto-threads
share one channel. `_relay_auto_thread_info` returns it directly (with an
empty initial-name marker) and never consults the collision-prone per-chat
cache; the connector's own created-name guard (`prefer_connector_created`)
still enforces no-clobber, so no initial name is needed gateway-side. The
send-result cache path stays as a fallback for older connectors that don't
stamp the field.
Tests: two new cases in test_relay_threads.py — prospective id wins over a
poisoned cache entry, and two sibling threads in one channel each rename to
their own thread id. Full gateway session + relay suites green (211 passed).
- Guard the thread-id-as-chat_id normalization to Discord only; Slack
and Telegram adapters use parent_channel as chat_id for thread messages,
so the unconditional version broke their handoff keys.
- Apply the same Discord-specific guard to _seed_cron_thread_session in
cron/scheduler.py (sibling site with the same bug, docstring said
'Mirrors _process_handoff').
- Replace the change-detector test with contract tests that verify the
actual invariant: Discord handoff key == organic thread key, Slack
handoff key still uses parent channel (non-regression).
A CLI→Discord handoff creates a dedicated thread and re-binds the CLI
session to it. It built the destination SessionSource with
chat_id = home.chat_id (the PARENT channel) while marking it
chat_type="thread" with thread_id set.
But platform adapters build organic in-thread messages with
chat_id = <thread id> (see the Discord adapter's on_message and
_build_thread_event paths). build_session_key therefore produced two
different keys for the same thread:
handoff: agent:main:<platform>🧵{parent}:{thread}
organic: agent:main:<platform>🧵{thread}:{thread}
So the next real user reply in the handoff thread resolved to a
DIFFERENT session_key and spawned a fresh session instead of continuing
the handed-off one — observed as a stray auto-titled session plus a
session_search fallback (the new session had no prior context).
Fix: for a thread destination, key on the thread's own id so the
synthetic handoff turn and later user replies share one session_key,
matching how adapters key organic in-thread messages.
Adds tests/gateway/test_handoff_thread_session_key.py, which asserts the
handoff key is byte-identical to the organic in-thread key (fails on the
old parent-channel keying, passes on the fix).
The _sync_codex_entry_from_auth_store source guard returned early for
source='manual:device_code', which is the recommended quarantine-safe
configuration (hermes auth add openai-codex produces SOURCE_MANUAL_DEVICE_CODE).
The PR's fix for refresh_token adoption was unreachable for these entries.
Widen the guard to accept both 'device_code' and 'manual:device_code'.
Follow-up to #70111. Issue reporter (imgyf) confirmed this caused a
12-of-16 fleet outage on Aug 1.
Co-authored-by: imgyf <imgyf@users.noreply.github.com>
Two defects in the openai-codex credential pool recovery path:
Defect 1 — adoption path silently no-ops when store_access is empty
_sync_codex_entry_from_auth_store() skipped adoption when the auth
store had no access_token (only last_refresh). When another process
rotated the token pair, the stale profile's entry kept the consumed
refresh_token and replayed it, getting refresh_token_reused and going
terminally DEAD.
Fix: also adopt when store_refresh differs from entry_refresh, even
when store_access is empty. Keep the entry's existing access_token
in that case (store_access or entry.access_token).
Defect 2 — false 'auth refreshed' success log
_try_refresh_codex_client_credentials() returned True whenever
resolve_codex_runtime_credentials() returned any non-empty credentials,
including the same stale token when the underlying refresh failed.
The conversation loop then logged 'auth refreshed after 401' right
before the retry failed with the identical token_expired.
Fix: compare the access token before/after the refresh. If unchanged,
return False so the 401-retry path logs the truth.
Fixes#70097
Replace the process-global HERMES_CRON_SESSION env var with a per-session
ContextVar so a cron tick in the gateway process cannot leak into unrelated
live gateway/API/TUI turns. The cron scheduler now sets the ContextVar
inside the job's try/finally scope and resets it on cleanup. Gateway, API
server, ACP adapter, and TUI gateway all pass cron_session='' to explicitly
mark their sessions as non-cron, masking any stale process env.
Co-authored-by: hinablue <hinablue@gmail.com>
Closes#37968
Completes the cache-parity bug class from #76938: the parent's request
body diverges from the fork's not only at the ephemeral system prompt
but also at prefill messages (inserted right after the system message
at API-call time) and, on OpenRouter, at upstream-provider selection
(prompt caches live per upstream; an unpinned fork can be routed to a
different upstream and miss a byte-identical prefix).
Also hardens the tests: pairwise asserts instead of re-implementing the
production prompt join, and routed-path omission guards for the whole
gated kwarg family.
_format_price_per_mtok collapsed any per-Mtok price below one cent to
"$0.00" (and readers treated near-zero as free). Nous Portal's DeepSeek
V4 Flash 0731 promo prices cache hits at $0.0018/Mtok, which rendered as
free in the /model picker and hermes model listings.
Prices under $0.01/Mtok now widen precision to the first significant
digit plus one, with trailing zeros trimmed: 0.0000000018/tok →
$0.0018. Standard prices keep the aligned two-decimal format; exact
zero still renders as "free".
Simplify-pass follow-up on the #68157 salvage (regression-neutral \u2014 the
old temp-file version was equally bare): capture ffmpeg's -loglevel
error output so a CalledProcessError carries the real message, and log
it at the voice-input catch site. Parity with transcription_tools'
ffmpeg call sites. Live-verified: forced ffmpeg failure produces the
captured message in the exception.
pcm_to_wav staged every captured utterance in a NamedTemporaryFile just to
hand ffmpeg an input path, then unlinked it. Feed the PCM to ffmpeg's stdin
instead: one fewer file created, written, read back and removed per voice
utterance, and the try/finally cleanup goes away with it.
The WAV output deliberately still goes to output_path rather than being
captured from stdout. ffmpeg cannot seek on a pipe, so a piped WAV is
written with placeholder 0xFFFFFFFF RIFF/data chunk sizes -- Python's wave
module then reports 2147483647 frames for a 1s clip, and strict readers
misjudge the length. Writing to the real path lets ffmpeg seek back and
patch the header.
Tests cover both halves: that the PCM goes over stdin with no temp file,
and (when ffmpeg is installed) that the resulting header reports the true
frame count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pass extra_exclude={pid} to _reap_unsupervised_gateway_orphans so the
killed PID isn't double-killed during the sweep (#75936).
- Add extra_exclude param to _reap_unsupervised_gateway_orphans signature.
- Replace bare except:pass with logger.debug for diagnosability.
- Fix existing test (mock _reap_unsupervised_gateway_orphans so it
doesn't scan real processes and trigger conftest live-system guard).
- Add regression test asserting the killed PID is excluded from the sweep.
stop_profile_gateway() only killed the single PID recorded in the pid
file. On repeated restarts, the new process overwrites the pid file
before the old one exits, making older gateway instances invisible
to subsequent stops. Each restart stacked another orphan — observed
as N threads and N replies per Discord message.
After killing the recorded PID, also call _reap_unsupervised_gateway_orphans()
to sweep any remaining gateway processes for this profile. The reap function
already handles the no-systemd guard and SIGTERM+SIGKILL escalation.
Fixes#75936
Re-applied from #50508 onto current main: the executor moved from
tools/ to agent/ and the JSON-error site was refactored away into
_parse_tool_arguments, but these 8 f-string debug calls survived the
move verbatim. Lazy %s formatting skips string interpolation when
DEBUG is off — the Tool-result line interpolates the FULL tool output
(can be 100KB+) on every tool call otherwise.
_handle_cron_fire verified the NAS-minted fire JWT by calling the
fire-verifier inline on the event loop. That verifier resolves the NAS
signing key from a JWKS URL — a synchronous HTTP GET on a cache miss (a
cold PyJWKClient, or a rotated kid the cached client doesn't know) — so a
slow or rate-limited portal stalls the whole event loop and starves every
other adapter sharing it. #64641 already documented this exact symptom
(relay 504s on high-job-count instances) and cut the fetch frequency by
caching the client per URL, but the residual cache-miss fetch still ran
inline on the loop.
Dispatch the verifier the same way the platform HTTP event verifier was
hardened: await a coroutine verifier directly, run a sync one via
asyncio.to_thread so its blocking I/O stays off the loop, and fail closed
(reject with 401, never admit the fire) if the verifier raises — this is
the only inbound that can trigger remote job execution. The verifier's
JWK-client cache is already thread-safe (threading.Lock), so moving the
call to a worker thread is safe.
Adds regression tests: a sync verifier runs on a worker thread rather
than the loop thread, a crashing verifier yields 401 with no fire, and a
coroutine verifier is awaited.
/api/status is the desktop's boot liveness probe (polled ~1/s) but since
#60537 every call ran a full topology scan — per-profile yaml.safe_load
(pure-Python loader), psutil process probes, realpath walks — in the
default executor. On multi-profile installs concurrent polls pile up and
hold the GIL 14-16s, starving the event loop: the WS sidecar cannot
flush gateway.ready, the desktop times out into the next stall, and boot
escalates to the 'Hermes couldn't start' overlay (#60800).
Memoize the scan behind a 10s TTL with a collapse lock so concurrent
polls share one scan. Topology only changes on gateway start/stop, so a
<=10s stale badge is an acceptable trade for not starving the loop. The
cache also keys on the collector's identity: tests monkeypatch
_collect_profile_gateway_topology per case, and the identity check keeps
them hermetic (a swapped collector is a miss) without a reset hook.
py-spy captures during a failing boot land in _profile_platform_ports ->
yaml.safe_load on executor threads (7 profiles, Windows). After: one
cold-start scan, zero recurring stalls, desktop boots.
- Short-circuit the candidate waterfall on HTTP 401/403: an auth wall
proves the endpoint family exists, so probing the alternate URL just
doubles the wasted wait (the reported endpoint takes ~10s to return
401 without a key).
- Stream the probe so 4xx never downloads a slow error body; responses
are closed on every exit path.
- Regression tests: single-call assertion on 401/403 (fails on main),
negative-cache reuse, 404 waterfall preserved, no .json() on 4xx.
Fixes#69905
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simplify-pass advisory on the #25387 salvage: _read_tracker_lock is one
global lock guarding every task's read/search bookkeeping (15 sites). A
hung stat on a dead network mount inside it would stall all of them.
Check-then-recheck matches the dedup mtime pattern 30 lines below: read
the entry under the lock, stat outside, reacquire only to evict.
Review follow-ups on the #25387 salvage:
1. CRITICAL: a cached miss survived out-of-band file creation (terminal
command, external process) for the full 60s TTL — breaking the common
agent pattern 'check for file -> create it -> read it' (live-repro'd).
Serve-side existence guard: one ~free stat before serving a cached
miss; if the path now exists the entry is evicted and the real read
runs. Also fixes the search-root variant (write under a cached-missing
directory). Both mutation-checked.
2. notify_other_tool_call now clears the task's not_found entries too
(belt: the dispatcher calls it for every non-read tool).
3. Tracking parity: the record sites no longer early-return. On upstream,
error results flow through consecutive-loop detection and dedup
bookkeeping; short-circuiting skipped that and broke
TestDedupInvalidationTaskResolution when preceded by
TestSilentFileMisplacementE2E (bisected: the early return at the
read record site was the trigger). Recording is now
side-effect-identical to upstream; serving from the cache remains the
optimization. Also reuse the already-computed _resolved instead of
resolving a second time.