Commit Graph

14116 Commits

Author SHA1 Message Date
kshitijk4poor eac466d7bd fix(browser): gate lightpanda --http-cache-dir on binary support
Salvage follow-up for PR #100269. The flag landed upstream in 0.3.x;
binaries before it (e.g. 0.2.8, verified locally) fatally reject the
flag with 'unknown argument', breaking every Browser Use launch.
Probe 'lightpanda help' once per process and omit the flag when the
binary predates it. Also soften the unverified concurrency claim in
the _http_cache_dir docstring and tell docs readers to stop sessions
before deleting the live sqlite cache.
2026-09-03 03:18:08 +05:30
Adrià Arrufat 6973ce3ac2 feat(browser): give Lightpanda an on-disk HTTP cache
Lightpanda's HTTP cache is opt-in (`--http-cache-dir`, off by default), and
the launcher never passed it, so every Browser Use navigation re-fetched
every asset.

Point all Hermes-spawned instances at one shared cache under
$HERMES_HOME/cache/browser-use/lightpanda/http-cache. Sharing it across
sessions keeps assets warm through session churn; Lightpanda stores it in
sqlite (WAL), so a write that loses a race degrades to a cache miss rather
than a failed load, and --http-cache-entry-limit (default 1000) bounds the
directory without Hermes managing eviction.

Measured over 25 navigations across 5 sites, median warm navigation drops
from 0.40s to 0.18s on news.ycombinator.com and 0.14s to 0.10s on
wikipedia; total navigation time 7.0s -> 5.9s.

The flag has existed since Lightpanda 0.3.x (April 2026), so this needs no
minimum-version bump.
2026-09-03 03:18:08 +05:30
kshitijk4poor 87597d30c8 perf(mcp): apply the -t spawn filter before the mcp SDK import
With servers configured but none selected by -t/--toolsets, discovery still
paid the ~260ms mcp SDK import before discovering it had nothing to spawn.
Filter first; the test now asserts the SDK probe is not reached.
2026-09-03 03:16:04 +05:30
kshitijk4poor af019a3716 fix(cli): apply the -t/--toolsets MCP spawn filter on every discovery path
The cherry-picked commit added the allowed_mcp_names filter to
discover_mcp_tools(). Since then CLI startup grew a second discovery path —
start_background_mcp_discovery / the deferred desktop start in
hermes_cli/mcp_startup.py — so wiring the filter only into the inline call
would leave `hermes chat -t terminal` (the default backgrounded path) still
spawning every server.

Store the filter once in mcp_startup (set_mcp_server_filter, called from
_prepare_agent_startup from args.toolsets; `all`/`*`/empty clears it) and
have both the inline and the background discovery honor it. The unfiltered
call shape is unchanged so zero-arg test stubs keep working.

Dropped from the original PR: the atexit/SIGINT/SIGTERM oneshot MCP reap —
main already does this in _cleanup_oneshot_runtime() -> shutdown_mcp_servers().

E2E (3 configured stdio servers, real subprocesses, 5 runs median):
no filter 3 spawned / 2.0 s; `-t terminal` 0 spawned / 1 ms.
2026-09-03 03:16:04 +05:30
kshitijk4poor f0006ef7b7 test(gateway): assert the three wrapped SessionDB calls actually ran
Guards the off-loop assertion against becoming vacuous if run.py ever hoists
the loops imports out of the watcher body (the class/module patches would then
be bypassed and on_loop_calls would be trivially empty).
2026-09-03 03:15:35 +05:30
kshitijk4poor 6aa6c988f6 test(gateway): bind fire_tick/complete_tick offload, not just list_active_loops
The writer-lock test exits at the no-adapter branch, so it only covered the
first of the three off-loop calls. Add a full-path test (adapter present,
slash-command loop) asserting each SessionDB call runs off the loop thread;
with main's run.py it fails naming all three.
2026-09-03 03:15:35 +05:30
kshitijk4poor 98428d20bc fix(gateway): route loop-wakeup DB calls through the context-preserving executor
Follow-up to the two cherry-picked commits: use _run_in_executor_with_context
(the file's established off-loop helper — keeps the multiplex HERMES_HOME
override alive in the worker thread, same as the warm-up above) instead of a
bare run_in_executor(None, ...), drop the now-stale rationale that blamed
list_meta_prefix's writer lock (main reads via _read_ctx), and add a
regression test that holds the SessionDB writer lock from another thread
during one watcher scan and asserts the event loop keeps ticking. On main
the same test measures a 0.53 s loop stall for a 0.6 s hold.
2026-09-03 03:15:35 +05:30
kshitijk4poor cfe88a1f7d feat(config): context_file_read_timeout key + narrow reader catch
Expose the read deadline as a top-level config.yaml key beside
context_file_max_chars (same load_config_readonly resolution shape), default
5s, documented in context-files.md. Narrow the reader thread's catch from
BaseException to Exception: control-flow exceptions can't originate inside
read_text on a worker thread, and re-raising one would bypass the sites'
except Exception / except (OSError, UnicodeDecodeError) handlers.
2026-09-03 03:15:31 +05:30
Edder Talmor 972b94bd29 fix(agent): time out slow context file reads
Context files (SOUL.md, .hermes.md/HERMES.md, AGENTS.md, CLAUDE.md,
.cursorrules, .cursor/rules/*.mdc) and subdirectory hint files are read on
the startup / per-turn path. On network-backed filesystems (iCloud Drive,
OneDrive, NFS) a cold read of an evicted file can block indefinitely, which
stalls system-prompt assembly before the first turn.

Read them on a daemon thread with a 5s deadline via _read_text_with_timeout;
a timed-out file is logged at WARNING and skipped so the next context source
still loads. Read errors propagate exactly as before, so each site's
existing try/except handling is unchanged.

Re-cut against current main from PR #10110 (the original diff predates the
context_length plumbing and the AGENTS.md directory-chain loader); helper,
sites and regression tests follow the original.
2026-09-03 03:15:31 +05:30
kshitijk4poor a1d5a976b3 fix(agent): never floor an anchored pressure figure; keep the estimator total on lone surrogates
Follow-ups from review of the two salvaged #87490 commits:

- _pressure_with_real_floor now applies only on the rough fallback branch.
  A valid usage anchor is provider-exact and wins as-is: on MoA turns the
  anchor deliberately uses the pre-fold aggregator usage while
  last_real_prompt_tokens holds the folded figure, so flooring the anchored
  value would re-add fan-out tokens the anchor exists to exclude. Docstring
  rewritten to describe the real path split (anchor since d3a1c46510).
- estimate_tokens_rough: encode with errors="replace". main's estimator
  never raised; text.encode() on a lone surrogate (routine in tool output,
  see message_sanitization) raised UnicodeEncodeError and would abort a
  turn where main produced a slightly-off number.
- Record the cl100k/o200k/Qwen2.5 calibration for the bytes/4 rule.
- tests: accented Latin within +10% of the ASCII rule; mixed Cyrillic/ASCII
  counts ASCII at one byte; lone surrogates don't raise; anchored pressure
  is never floored (wiring shape).
2026-09-03 03:09:06 +05:30
Darafei Praliaskouski f07b6ff426 fix(agent): count non-CJK sparse text by UTF-8 bytes in the rough estimator
The ~4 chars/token rule is calibrated for ASCII; Cyrillic, Greek, Arabic and
similar 2-byte scripts tokenize at ~2-3 chars/token, so chars/4 under-counts
them ~2x and the pre-flight pressure figure trails real usage by tens of
percent on non-English sessions. Counting UTF-8 BYTES at ~4/token uses the
encoding width itself as the corrective: ASCII is unchanged (1 byte/char),
2-byte scripts count at chars/2, and the CJK dense path keeps its explicit
~1 token/char rule with the sparse remainder byte-counted. The ASCII
isascii() O(1) fast path is preserved; the non-ASCII paths add a single
C-level encode over text that was already being regex-scanned.

Complements the last-real-prompt floor: the floor catches sessions that are
already at the ceiling, this keeps the estimate from lagging in the first
place.
2026-09-03 03:09:06 +05:30
Darafei Praliaskouski 73b8ec3cea fix(agent): floor pre-API compaction pressure at the last real prompt size
The chars/4 rough estimate under-counts Cyrillic and other non-ASCII scripts
by up to ~2x, so a session can ride the provider's real context ceiling while
the rough pressure stays under the compaction threshold. On providers that
silently clip over-window prompts (ollama /v1) the reactive overflow handler
never fires either, and the length-continuation retry path re-enters the API
call without passing the post-response gate — reproducing the truncation
death spiral this branch already addresses (observed live after the first
commit: real prompts 64,842 -> 64,995 against a 55,705 threshold, output
room shrinking 694 -> 541 tokens).

Floor the pre-API pressure figure at the provider's last reported
prompt_tokens — authoritative, script-independent — except for the one turn
after a compaction when that value is known-stale (#36718's
awaiting_real_usage_after_compression window).
2026-09-03 03:09:06 +05:30
Enzo Adami 02a6e95228 fix(files): allow one full read after compaction 2026-09-03 03:07:37 +05:30
webtecnica 4a0aae8aae fix(agent): preserve tool-output dedup state across context compaction (#84857) 2026-09-03 03:07:37 +05:30
joaomarcos a89f6aba44 fix(compaction): stop idle compaction re-summarising a transcript that has not grown
Idle compaction (`compression.idle_compact_after_seconds`) skipped work only
when the context was at or below `threshold_tokens × summary_target_ratio`.
That is a *theoretical* post-compaction target: a real pass lands well above
it because the system prompt, the tool schemas and the protected head/tail
are an incompressible floor.

So a session that compacted to well above the target stayed above it forever,
and every later idle resume re-ran a full summarisation over a transcript that
had not grown. In the reported session the 17:01 pass reduced 64,105 -> 44,579
tokens; the 17:36 resume re-fired because 44,579 was still above the 25,502
theoretical floor, blocking the prompt for another 256 s on a ~55 tok/s local
route and reclaiming nothing. Neither pass was followed by a single API call.

`_should_idle_compact` now also honours `last_compression_rough_tokens` — what
the previous pass on this session actually produced, recorded by
`compress_context` with the same `estimate_request_tokens_rough` shape the idle
estimate uses. When it is known, the transcript must accumulate at least one
`floor_tokens` worth of new content on top of it before another pass is worth
its wall clock. A raised floor is a deferral, not an off switch.

`0` — nothing compacted yet, or the counter cleared by a rebind/recalibration
— keeps the original semantics exactly, so the first idle compaction of any
session is unaffected. The call site type-pins the read so compressor doubles
that expose a Mock there fall back to the original floor.

Fixes a root cause behind #97239. Deliberately does not touch the TUI status
surface (#97253), the digest chain / total ceiling (#93241), or turn-settle
scheduling (#96891).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HV3k1v5nx9ag5d5wXSFZ7o
2026-09-03 03:07:33 +05:30
Gille 48bf176701 fix(installer): order repository before managed Python 2026-09-02 17:26:25 -04:00
kshitijk4poor 4f5ddd5473 test(file-ops): make the unicode-variant retry test filesystem-independent
Follow-up to the salvaged #95160. The test wrote the NFC spelling of
café.txt and read the NFD spelling. APFS resolves NFD lookups to the NFC
file directly, so on macOS the compound probe simply found the file, the
retry path never ran and r.hint was None -> TypeError (Linux CI passed
because ext4 does not alias normalisation forms).

Use a curly-vs-ASCII apostrophe instead: still visually identical, still
mapped by _unicode_variant_match, and never aliased by any filesystem, so
the retry path is exercised everywhere.
2026-09-03 02:27:52 +05:30
Alex Jia e1c0c4bb6e chore(file-ops): log compound probe fallbacks at debug level
(cherry picked from commit 345f8994f3d422eb39f1807006ca41ce9086c65a)
2026-09-03 02:27:52 +05:30
Alex Jia 47a8f6148a test(file-ops): pin native read parity on 1 MiB chunk boundaries
(cherry picked from commit f72338b4d75d2a70048014523d4a55d5a4f30cbd)
2026-09-03 02:27:52 +05:30
Alex Jia d3275acf80 perf(file-ops): native read_file fast path on local POSIX environments
(cherry picked from commit 04b27898724d5ca756bf1e7c61578bd28986364a)
2026-09-03 02:27:52 +05:30
Alex Jia 9fbb3b716a perf(file-ops): merge write_file's pre-write probes into one shell call
(cherry picked from commit fe68f00b22dcfe4706e38a3ca4f310b7501ba131)
2026-09-03 02:27:52 +05:30
Alex Jia 6a2b3f1ebf perf(file-ops): collapse read_file's shell probes into one compound command
(cherry picked from commit 4e6c0beff77e24c46641d09ce77258c819ea9098)
2026-09-03 02:27:52 +05:30
kshitij 04eaaab410 Merge pull request #101622 from kshitijk4poor/fix/92164-utf8-seam
fix(process): keep the sandbox log poller from splitting a UTF-8 character across polls
2026-09-03 02:26:12 +05:30
kshitijk4poor 126668c72d fix(mcp): wait() the released supervisor so it does not linger as a zombie
Phase-2c finding on #101598: the empty-set release closed the pipe and
dropped the Popen without reaping it, so an idle gateway that once
connected a stdio server held one zombie until the next Popen in the
process. wait(timeout=5) after close -- the supervisor exits on EOF
immediately with nothing registered (E2E: ps shows no entry at all).

Also: the replay test asserted against the last line only, which could
not catch the regression it names; assert against the full stream and a
post-unregister replay. Module docstring now states the stdlib-only /
no tools/ import constraint and why _reap's sweep is a deliberate copy.
2026-09-03 02:24:06 +05:30
kshitijk4poor 5b3de24122 test(mcp): pin scoped teardown of one owner leaves the other owner supervised
Contract requested in the #93517 review, now that main has profile-scoped
MCP ownership: two owners each hold a real stdio process group; a scoped
_kill_orphaned_mcp_children(include_active=True, server_name=A) reaps
only A's group, sends 'unregister' only for A, and the per-process
supervisor still holds B.
2026-09-03 02:24:06 +05:30
kshitijk4poor 8f5749db4b fix(mcp): respawn the death supervisor on any lifecycle event while groups remain
Addresses @andrexibiza's Blocker 1 on #93517 (reproduced): after a
broken-pipe write dropped the supervisor while groups were still
registered, the no-spawn fast path was keyed on the incoming verb
(`unregister` + no proc => return), so a clean teardown of one server
left the survivors recorded in _supervised_pgids but unsupervised until
the next register. Key the fast path on the supervised set being empty
instead; any later call respawns and replays the survivors.

Regression test: two live groups, write fails, unregister one ->
replacement receives `register` for the survivors. Mutation-checked
against the old verb-keyed guard.
2026-09-03 02:24:06 +05:30
kshitijk4poor 4a2b23d77e fix(mcp): release the death supervisor once nothing is left to reap
Follow-up to the salvaged #93517. Two gaps found in review:

- After the last unregister the supervisor stayed resident for the life of
  the process (~15 MB + a pipe) in any gateway/cron that ever connected a
  stdio server; main's per-server watchdog exited with its server. Close
  our write end when the supervised set empties: EOF with nothing
  registered makes the supervisor exit without reaping, and the next
  register already respawns and replays.
- A child that raced and exited before os.getpgid dropped its group from
  coverage entirely. The SDK spawns stdio servers as session leaders
  (pgid == pid), so fall back to the pid instead; the prune forgets the
  group once nothing in it is alive.

Tests: fake-supervisor release/re-spawn sequence, and a real-process EOF
release (supervisor exits 0, unregistered child untouched). Mutation
checked: disabling the release branch fails both.
2026-09-03 02:24:06 +05:30
John Paul Soliva b5b2ab1bb0 fix(mcp): forget supervised groups once nothing is left alive
Review raised a real gap: we reap by pgid, so a registration is only as
meaningful as the group's identity. A group we deliberately keep registered --
an orphan teardown failed to kill, such as the `node` mcp-remote leaves behind
-- can later exit on its own, after which the kernel may hand that pgid to an
unrelated process owned by the same user. An ungraceful Hermes death while the
registration is stale would then signal a stranger. `_is_safe_target` cannot
catch it, because the value is stale rather than invalid.

Prune registrations whose group has no members left on every registration
change, and tell the supervisor to forget them. Signal 0 is a pure existence
question -- it cannot terminate anything -- so this is cheap and safe to run on
the hot path. An ambiguous answer (EPERM: exists but not ours) keeps the
registration, since dropping real coverage is the more expensive mistake.

This narrows the window rather than closing it: a group can still die and its
pgid be recycled between two probes. Closing it completely means proving group
identity at reap time, e.g. stamping children with a boot-unique env marker and
checking a member still carries it. That was judged not worth putting a `ps`
parse into the one process whose job is to stay simple enough to always work,
so the residual is now documented in the module docstring instead, along with
the note that Hermes's existing killpg-based orphan cleanup already carries the
same exposure (upstream #88350).

Also records why supervisor recovery is deliberately two-step: a failed write
drops the handle, and the next call rebuilds coverage from `_supervised_pgids`,
which -- not the pipe -- is the record of what needs reaping.

The protocol tests register synthetic pgids that were never real process
groups, so they now state that precondition through an `all_groups_alive`
fixture instead of depending on pid-space luck.

29 tests pass. Verified the change adds no failures: same selection, my changes
stashed vs applied, 42 failed either way (pre-existing in a locally rebuilt
venv) with passed going 617 -> 619 for the two new tests. Footgun linter clean.

(cherry picked from commit 3a7d219620a8cb57a7fe2c2346ce3f055320e743)
2026-09-03 02:24:06 +05:30
John Paul Soliva 2d783a15eb perf(mcp): one parent-death supervisor per process, not one per stdio server
Every stdio MCP server was wrapped in its own CPython watchdog that polled
getppid() every 2s to notice an ungraceful Hermes exit (kill -9, OOM, crash,
force-quit), since macOS has no PR_SET_PDEATHSIG. That is a whole interpreter
per server for a job that does nothing until the moment Hermes dies: 10.1 MB
physical footprint each, measured on macOS/arm64.

Replace the fleet of pollers with a single supervisor per Hermes process
holding the read end of a pipe only Hermes writes to. Death detection becomes
EOF on that pipe -- exact and instant, rather than up to a poll interval late.
Hermes sends `register <pgid>` / `unregister <pgid>` as servers come and go;
on EOF the supervisor killpg's whatever is still registered, which is exactly
the set whose teardown never ran. A clean shutdown unregisters as it goes, so
EOF then finds nothing to kill.

Servers are now spawned unwrapped. The MCP SDK already starts each stdio child
in its own session, so the pgid recorded for killpg is the server's own group
and the existing cleanup paths reach it unchanged. That also deletes the
signal-forwarding layer the wrapper needed: wrapping had put the real server in
a different session from the pgid being tracked, so a graceful killpg would
have hit only the wrapper.

Measured on a 5-gateway host: 10 watchdogs (~98 MB) -> 5 supervisors (~49 MB).
One supervisor costs about what one watchdog did (9.9 vs 10.1 MB), so the win
is (servers_per_process - 1) x ~10 MB, and a process with no stdio servers now
spawns nothing at all.

The supervisor reads length-capped lines rather than iterating the stream: a
writer that never sends a newline would otherwise grow it without bound, which
it must not be vulnerable to when it is the last defense against leaked
servers. Found by feeding it /dev/zero, where it reached 15 GB.

Verified beyond unit coverage: a real stdio MCP server connects and its tools
are discovered on the unwrapped path; with a live Hermes holding a real
connection, kill -9 reaped the server, its grandchild (in the server's group,
the mcp-remote `node` case), and the supervisor exited on its own. The reap
tests were sabotage-checked in both directions -- a no-op reaper fails all
three, while the test pinning that a cleanly unregistered server survives keeps
passing -- and each wiring half fails independently when removed.

(cherry picked from commit a252d4ce7ff1722f687635fdbf0cff79f538c3f1)
2026-09-03 02:24:06 +05:30
kshitijk4poor 75bcd86687 fix(process): keep the sandbox log poller from splitting a UTF-8 character across polls
Follow-up to #92164: the delta window now ends on a character boundary
(up to 3 trailing continuation bytes are held for the next poll), so
multibyte output no longer decodes to U+FFFD at the seam. Verified on
bash, dash and busybox sh; exhaustive-prefix regression test added.
2026-09-03 02:20:46 +05:30
kshitijk4poor 9954445fea fix(feishu): create the dedup persist lock lazily for bare-constructed adapters
tests/gateway/test_feishu_bot_admission.py builds FeishuAdapter via
object.__new__ and installs dedup state by hand (install_dedup_state), so
the asyncio.Lock added in __init__ was missing -> AttributeError in CI.
Resolve the lock through a getattr-with-create helper and add it to the
shared test helper.
2026-09-03 02:17:01 +05:30
kshitijk4poor 1730a2c32a fix(gateway): serialize offloaded persists so concurrent flushes land in order
Offloading the fsync to a worker thread (previous commit) lets two inbound
messages have two persists in flight. Without ordering, a slow first flush
lands after the second and the disk file loses the newer token/id/seen-id
(in-memory state stayed correct, so the loss only surfaced after restart).
The loop-side dict could also be mutated while the worker iterated it.

- weixin ContextTokenStore / discord non-conversational tracker: snapshot
  the payload on the loop, hand it to the worker, serialize with a per-store
  asyncio.Lock.
- feishu dedup: same asyncio.Lock around the offloaded persist (its snapshot
  was already taken under _dedup_lock; the write was not).
- Regression tests: two concurrent persists with a slow first write must
  leave the union on disk. All three fail on the previous commit.
2026-09-03 02:17:01 +05:30
pierrenode cf6547da47 fix(gateway): offload remaining atomic_json_write calls on platform-adapter hot paths
atomic_json_write() calls os.fsync(), which blocks until the write
reaches stable storage. #83906 offloaded this in channel_directory.py,
gateway/run.py, and gateway/slash_commands.py, but the same blocking
pattern was still present on three platform-adapter message hot paths
that were never touched by that pass:

- plugins/platforms/feishu/adapter.py: _is_duplicate() persists the
  dedup cache on every new inbound message (shared by both websocket
  and webhook transports). The in-memory bookkeeping stays under
  _dedup_lock; the fsync now runs off it via asyncio.to_thread so
  concurrent callers on the ws-thread loop aren't blocked on disk I/O.

- gateway/platforms/weixin.py: ContextTokenStore.set() persists the
  context-token cache on every inbound message carrying a
  context_token, called from the async poll-loop handler
  _process_message.

- plugins/platforms/discord/adapter.py: mark_many() persists the
  non-conversational message-id tracker, called from both send() and
  send_update_prompt() when a message is tagged nonconversational.

Each fix mirrors the existing asyncio.to_thread(atomic_json_write, ...)
pattern from #83906. Regression tests assert the persist call runs on
a different thread than the event loop, mirroring
test_directory_write_runs_off_event_loop_thread in
test_channel_directory.py.
2026-09-03 02:17:01 +05:30
kshitij 4536e630a8 Merge pull request #101615 from kshitijk4poor/fix/w2-offloop-followups
fix(gateway): off-loop review follow-ups (negative-cache fast path, buzz read off-loop)
2026-09-03 02:12:23 +05:30
kshitijk4poor 962b35b4c7 fix(agent): probe teardown uses signature check and skips the shared SSH master
Follow-up to the cherry-picked fix:
- decide force_remove by inspect.signature (the idiom cleanup_vm already
  uses) instead of catching TypeError, which would misread a TypeError
  raised inside cleanup() as a signature mismatch and call cleanup twice
- skip teardown for ssh: SSHEnvironment has no task-scoped sandbox and its
  cleanup() runs sync_back + 'ssh -O exit' on a ControlMaster socket keyed
  by user@host:port, shared with the agent's real environment
2026-09-03 02:07:42 +05:30
Fatmylin 9640f8e1f8 fix(agent): tear down the backend probe's throwaway sandbox
`_probe_remote_backend` spins up an environment with
`task_id="prompt-backend-probe"` purely to run one `uname` for the system
prompt, then drops the reference without cleaning up. Container backends
default to `container_persistent` / `docker_persist_across_processes`, so
that throwaway sandbox stays up for the entire process lifetime, sitting
idle next to the agent's own `default` sandbox.

Observed on a three-profile Docker-backend host: six running containers
where three were doing any work. Every profile permanently paid for one
extra container whose only job had been a single `uname` at startup.

Fix: wrap the probe in `try/finally` and call
`env.cleanup(force_remove=True)` — the explicit-teardown path that
`DockerEnvironment.cleanup` already documents ("No current caller passes
`force_remove=True`; the parameter is here so the explicit-teardown
semantics can be wired up later"). Backends that inherit the kwarg-less
base `cleanup()` fall back to the bare call.

Verified on a live aarch64 Docker backend: the probe container is now
created, used and reclaimed within the same call. Three regression tests
cover the success path, the exception path, and the kwarg-less fallback;
all three fail without the source change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLbMzWqHVTPNBFisM8UiNB
2026-09-03 02:07:42 +05:30
kshitijk4poor e0567770f3 fix(gateway): off-loop review follow-ups for #101603 / #101605
Findings from the efficiency review pass on the two salvages, applied as one
small follow-up:

- copilot_auth: check the negative cache BEFORE taking the per-fingerprint
  exchange lock. During the 60 s post-failure window, dashboard polls now
  raise immediately instead of parking an executor thread behind the
  in-flight holder (up to ~50 s) to learn the same answer. Test hangs
  without the check (timeout 124), passes with it.
- buzz _localize_inbound_media: download_path.read_bytes() was still
  evaluated on the loop as the argument to the offloaded cache call — up to
  the 128 MiB inbound cap. Read off the loop too.
- test_list_credential_pool_keeps_loop_responsive: 0.5 s block / 0.25 s
  threshold (2x margin) so runner descheduling cannot false-fail it while a
  real regression still trips it.
2026-09-03 02:07:05 +05:30
Adolanium 4a17802280 perf(constants): remember the resolved Hermes home key
`hermes_home_key` called `Path.resolve()` on every call, which is a
filesystem call. `ToolRegistry.current_scope_key()` calls it, and
`_merged_tools()` calls that, so every single registry lookup went to
the disk: `get_entry`, `get_toolset_for_tool`, `get_emoji`, and the rest.

Anything that looks up many tools in a row paid for it. Building the
deferred-tool catalog for `tool_search` calls `get_entry` once per tool,
so with 400 deferrable tools one search did 800 path resolutions. On
Windows that is `nt._getfinalpathname`, and it took 132ms of the 148ms
the catalog build spent. The tokenizing everyone would suspect first was
under 4ms of it.

The resolved value is now remembered per input path. A process only sees
a handful of home paths, so the dict stays tiny. Measured on the same
400-tool catalog build, the lookups drop from 132ms to 3.1ms with one
entry cached.

A path that does not exist yet is resolved but not remembered, because
the answer can change once it is created, for instance when part of the
path turns out to be a link. A different home path is a different key,
so switching profiles or `HERMES_HOME` gets its own entry rather than
reusing a stale one.
2026-09-03 02:06:56 +05:30
Christopher c7a87bb110 fix(tools): retry OSV disk-cache load after transient I/O
A busy or briefly unreadable cache file must not disable disk loads for
the rest of the process; only missing or permanently malformed files
should mark the cache as loaded.
2026-09-03 02:05:42 +05:30
Christopher 8196d409a0 perf(tools): persist OSV malware-check verdict cache to disk
The OSV preflight is a synchronous network POST to api.osv.dev (up to 10s timeout) on every MCP stdio server start. Tools like `hermes mcp test` and MCP reconnect ladders spawn the same package repeatedly, but the previous in-process cache was empty after every process restart, so each run re-queried OSV and added 5.91x variance to the connection-time span.

Persist the malware-check verdict cache to `<hermes_home>/cache/osv_check.json`. Cache expiry is stored as an absolute wall-clock timestamp, so it survives restarts and monotonic-clock skew. Loading only adds missing keys so an in-memory overwrite (e.g. a test forcing expiry) is not silently reversed by the disk copy. Writes are atomic (temp file + rename) and happen under the existing cache lock.

Fixes the Hermes-owned OSV preflight variance component of #68416. Server-side `initialize` time is outside Hermes' control.

- Adds `hermes_constants.get_hermes_home()` lazy import to keep `tools/osv_check.py` import-safe (stdlib + typing only at module scope).

- Switches cache timestamps from `time.monotonic()` to `time.time()` for persistence compatibility.

- Updates `tests/tools/test_osv_check.py` fixture to isolate disk cache per test via `HERMES_HOME` + `tmp_path`, and adds regression tests for persistence, reload, and disk format.
2026-09-03 02:05:42 +05:30
Adolanium 8b681f70ea perf(process): poll sandbox job logs for new bytes only
The background-process poller for non-local backends ran `cat` on the
whole log file every two seconds, then threw away everything except the
part it had not seen yet. The offset it needed was already tracked one
line below, so the full read was pure waste.

Cost of one poll grew with the total output so far, which makes the cost
of a run grow with the square of its length. A job writing 10 MB over an
hour moved about 9 GB across the docker or SSH channel to deliver 10 MB
of output.

The poller now asks the shell for the file size and the bytes after the
offset in one command. Reading the size first and cutting the tail at
that same size keeps the two in step, so a file that grows mid-command
never sends a byte twice. A file that shrank was rotated or truncated,
so the offset drops back to 0 and the buffer is dropped.

The output buffer is now appended to rather than replaced, matching the
local reader loops, and the offset is counted in bytes because the shell
counts bytes.
2026-09-03 02:00:35 +05:30
kshitijk4poor 568b16122d fix(gateway): finish the media-cache offload sweep (teams NameError, buzz, photon)
The previous commit removed cache_media_bytes from teams/adapter.py's import
block but left one bare call at the Bot-Framework image path — a NameError
that the surrounding 'except Exception' swallowed, so every BF image
attachment was silently dropped (ruff F821). Route it through
cache_media_bytes_async like the file's two other sites.

Same bug class at the sites the sweep did not reach:
- buzz _download_attachment / _localize_inbound_media: cache_media_bytes
  called from async methods → cache_media_bytes_async.
- photon _dispatch_inbound: _normalize_binary_payload (base64 decode +
  cache write of possibly multi-MB payloads) ran on the loop → closure made
  async, helper offloaded via asyncio.to_thread.

Tests: cover cache_media_bytes_async (thread-id + kwarg forwarding);
teams/buzz tests re-pointed at the async seam.
2026-09-03 01:58:56 +05:30
fangliquanflq cb0b66c161 perf(gateway): offload inbound media cache writes 2026-09-03 01:58:56 +05:30
kshitijk4poor a3d33fe22f fix(copilot): single-flight the token exchange and close abandoned responses
Follow-up to the off-loop move: once the credential-pool handlers run on
worker threads, the dashboard's periodic /api/credentials/pool polls can
overlap, and during a DNS outage each poll would have started its own
exchange and abandoned its own hung resolver thread.

- Per-fingerprint threading.Lock around the exchange: concurrent callers
  wait on the one in-flight attempt, then hit the positive or negative
  cache (bounded worker count, no duplicate network calls).
- _urlopen_bounded: when the hard cap fires and the abandoned worker later
  succeeds, close the HTTPResponse instead of leaking the socket.
- Tests (none shipped with the original PR): hard cap + late-close,
  single-flight success and failure paths, and the pool endpoint running
  off-loop / keeping the loop responsive under a 200 ms blocking read.
2026-09-03 01:57:09 +05:30
kshitijk4poor 2b4e70ec07 refactor(cli): use the router's run_in_threadpool alias; offload profile model write
The module already binds run_in_threadpool (used by list_profiles_endpoint)
and every sibling router uses the same starlette helper; the nine new
loop.run_in_executor(None, _run) sites now go through that alias so the
file has one offload idiom. Behaviour-identical (both hand the callable to
a worker thread).

Also sweeps the one endpoint the PR left synchronous:
update_profile_model_endpoint's _write_profile_model reads and rewrites
the profile's config.yaml on the event loop.
2026-09-03 01:56:47 +05:30
briandevans beb2e91d04 test(cli): cover the profiles router off-loop sweep
Two assertions per offloaded site:

- a loop probe, where the stubbed callee records whether an event loop is
  running in its own thread — the idiom already used by
  tests/hermes_cli/test_cron_dashboard_off_loop.py; and
- a concurrency proof, where the stubbed callee blocks on a threading.Event
  while an unrelated request is timed. On the unfixed handlers that request
  waits out the whole block; served off the loop it returns in
  milliseconds.

The concurrency proof needs a single event loop across requests, so the
client fixture enters the TestClient context manager: that pins one
blocking portal for the whole fixture, where a bare TestClient(app) would
spin up a fresh loop per request and pass even unfixed.

Also covers the status-code mapping through the executor hop (404 on a
missing profile, 400 on a rename collision, 404 from the resolve that stays
on the loop ahead of describe-auto) and the _MISSING sentinel cases: a
desktop.json holding `null` still reports exists=true, an absent one
reports exists=false, and an empty SOUL.md is still distinguishable from a
missing one.

The client fixtures read web_server._SESSION_TOKEN from the module rather
than pinning a literal. web_server resolves that token once at import, so
whichever test file imports it first fixes the value for the session and a
later monkeypatch.setenv is silently ignored — two files hardcoding
different tokens would 401 depending on collection order.
2026-09-03 01:56:47 +05:30
briandevans f786c8699b test(proxy): cover the retry credential blocking the proxy event loop
Extends the off-loop suite to the third and last blocking method on the
`UpstreamAdapter` contract, the 401/429 rotation.

As with the two existing pairs, the primary assertion is **thread identity**,
not latency: a latency assertion measured by an HTTP client on the blocked
loop is vacuous, because the client's own timer cannot advance until the
block ends and it therefore reports a fast response on provably frozen code.

  * `test_get_retry_credential_runs_off_the_event_loop` records
    `threading.get_ident()` inside the fake adapter and compares it to the
    loop thread, and checks the rotation still works end to end (rejected
    bearer forwarded first, rotated bearer second).
  * `test_event_loop_keeps_running_while_the_retry_credential_resolves`
    samples a loop-side heartbeat counter from inside the stalled adapter. On
    the unfixed handler it records exactly 0 loop iterations across a 0.5s
    rotation.
  * `test_retry_credential_failure_still_returns_the_upstream_rejection`
    guards the error contract the change must leave alone: a raising rotation
    is still swallowed and the upstream's own 401 is streamed back, with no
    second forward.

A new `_build_rejecting_upstream` harness drives the `status in {401, 429}`
branch by rejecting every bearer except the rotated one.
2026-09-03 01:56:32 +05:30
briandevans 4b2f16d828 test(proxy): cover health responsiveness while the auth store is locked
Extends `test_proxy_off_loop.py` with the `/health` half, using the same
two-assertion shape as the credential tests:

- `test_is_authenticated_runs_off_the_event_loop` compares the thread the
  adapter's `is_authenticated` ran on against the loop thread. Before the
  fix they are the same ident.
- `test_event_loop_keeps_running_while_health_resolves_auth_state` reads a
  loop-side heartbeat counter sampled by the adapter across its own stall.
  Before the fix exactly 0 iterations run across 0.5s.

Both also assert the response is unchanged (`200`, `authenticated: true`),
so the offload cannot quietly alter what `/health` reports.
2026-09-03 01:56:32 +05:30
briandevans d4c2c74ece test(proxy): cover credential resolution blocking the proxy event loop
Adds `tests/hermes_cli/test_proxy_off_loop.py`, mirroring the harness in
`test_proxy.py`: the proxy and a fake upstream run as real aiohttp
servers on ephemeral ports under a single `asyncio.run`, guarded by
`pytest.importorskip("aiohttp")` — no pytest-aiohttp dependency.

The primary assertion is thread identity, not latency. A latency
assertion measured with an HTTP client on the blocked loop is vacuous:
the client's own timer cannot advance until the block ends, so it reports
a fast response on code that was provably frozen.

- `test_get_credential_runs_off_the_event_loop` records
  `threading.get_ident()` inside the adapter and compares it to the loop
  thread. Before the fix both are the same ident.
- `test_event_loop_keeps_running_while_credentials_resolve` runs a
  heartbeat task on the loop and has the adapter sample its counter on
  entry and exit, so the reading is taken from the loop rather than
  through a client that shares it. Before the fix exactly 0 iterations
  run across a 0.5s stall; after it, ~50.
- `test_credential_failure_still_maps_to_401` pins the error contract
  across the change of call form. It is deliberately not in the
  red-before set — it guards behaviour the fix must leave alone.
2026-09-03 01:56:32 +05:30
kshitijk4poor 5f24f291c2 fix(curator): carry .git file pointers too and document the deleted-skill drop
Submodule and worktree checkouts store .git as a file (gitdir: pointer);
the carry-over only looked at directory names, so that form was still lost
on rollback. Handle files with the same guard. Docstring now states the
deliberate limit: an excluded entry whose skill dir the target snapshot
lacks is dropped with staging (no orphan .git) and is not undoable via the
safety snapshot, which excludes these paths as well.
2026-09-03 01:43:22 +05:30