fix(desktop): avoid GIL stalls during auto-compaction#5
fix(desktop): avoid GIL stalls during auto-compaction#5infinitycrew39 wants to merge 3016 commits into
Conversation
requires-python is >=3.11,<3.14, so '3.11+' was misleading (implied 3.14+ works). Fixed in CONTRIBUTING.md and the docs-site mirror.
…esearch#59573) When a bundled web provider (firecrawl, tavily, exa, ...) is listed in plugins.disabled, its provider never registers and the web_search/ web_extract dispatchers emitted the misleading "No web extract provider configured. Set web.extract_backend to ..." — even though the backend was configured correctly. The real fix is to re-enable the plugin. - web_tools.py + web_search_registry.py: when the configured backend names a disabled bundled web plugin, both dispatchers now point the user at the actual cause (re-enable the plugin) instead of a wrong config hint. - plugins_cmd.py cmd_enable: enabling by canonical key now also clears the manifest-name alias (web-firecrawl) from plugins.disabled, so the suggested command actually re-enables the plugin ('explicit disable wins' matches on the name too). - plugins_cmd.py cmd_toggle / _run_composite_ui / _run_composite_fallback: the interactive 'hermes plugins' menu now persists the canonical key (web/firecrawl), never the bare manifest name — the drift that put the offending entry in plugins.disabled in the first place. Follow-up to NousResearch#59518 (which fixed web credential resolution, a different cause). Fixes the disabled-plugin symptom reported after that PR.
NousResearch#59567) Follow-up to NousResearch#59524. The one-shot running-claim stale-recovery window was a fixed 30-min constant. Derive it from the cron inactivity timeout instead (HERMES_CRON_TIMEOUT, the same limit the scheduler enforces per run) so the safety valve tracks how long a run may actually go quiet: - unset/invalid -> default 600s inactivity -> TTL 1800s (unchanged behaviour) - positive N -> max(N * 3 headroom, 1800s floor) - 0 (unlimited) -> no finite bound -> fall back to the 1800s constant The fixed constant is kept as the floor + unlimited-case fallback. Resolved once per due-scan. HERMES_CRON_TIMEOUT is a pre-existing internal env var (already read by cron/scheduler.py); no new config surface. E2E: with HERMES_CRON_TIMEOUT=1200 the claim now survives to 60min where the old fixed 1800s constant wrongly expired it at 30min mid-run. +1 derivation test; 640/640 cron tests pass.
…trator Introduces a first-class secret-source contract so password managers (Bitwarden today, 1Password next, third-party vaults as plugins) plug into one orchestrated startup path instead of each hardcoding into env_loader. - agent/secret_sources/base.py: SecretSource ABC (fetch-only contract: never raises, never prompts, sync with orchestrator-enforced timeout), shared ErrorKind taxonomy, FetchResult, run_secret_cli() minimal-env subprocess helper, API versioning for plugin compatibility. - agent/secret_sources/registry.py: registration gating (name/scheme uniqueness, api_version, shape), apply_all() orchestrator owning precedence (mapped-beats-bulk, first-claim-wins, override_existing never crosses sources, protected bootstrap tokens), conflict warnings, per-var provenance, per-source wall-clock timeout. - Bitwarden converted to a registered BitwardenSource (bulk shape); behavior unchanged, apply_bitwarden_secrets kept as legacy shim. - env_loader._apply_external_secret_sources now drives the orchestrator; provenance labels resolve through registry (e.g. '(from 1Password)'). - PluginContext.register_secret_source() for external backends. - secrets.sources optional ordering key in DEFAULT_CONFIG + example. - tests/secret_sources/: 47 new tests incl. reusable conformance kit (SecretSourceConformance) that plugin authors run against their source.
…ources Pull the disk-cache + FetchResult substrate out of bitwarden.py into a new agent/secret_sources/_cache.py: FetchResult, CachedFetch, is_valid_env_name, and a generic DiskCache (atomic mkstemp -> chmod 0600 -> os.replace write, 0700 cache dir, TTL-gated read AND write). Bitwarden now consumes it via a module-level DiskCache instance and thin wrappers, so the security-sensitive atomic-write/0600/TTL logic lives in exactly one place instead of being copy-pasted per backend (and drifting). Behavior is unchanged — the full Bitwarden suite passes untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve provider credentials from 1Password op://vault/item/field references
at startup via the official `op` CLI, alongside the existing Bitwarden source.
Users map env-var names to references in secrets.onepassword.env; after .env
loads, each is resolved with `op read` and injected into os.environ. Auth is
whatever `op` already uses (service-account token or desktop/interactive
session) — Hermes never authenticates or installs `op` itself.
Startup-safe and fail-open: a missing binary, expired auth, a bad reference,
or an empty value each warn and fall back to existing credentials, never
blocking startup. Successful, complete pulls are cached in-process and on disk
(<hermes_home>/cache/op_cache.json, 0600) via the shared DiskCache; only
secret values are stored, never the token (auth is fingerprinted into the
key). Adds `hermes secrets onepassword {setup,status,set,remove,sync,disable}`
(aliases op/1password), config defaults, the cli-config example, docs, and
hermetic tests.
Hardening applied across both backends in env_loader: each source runs in its
own guard, config sections are coerced to dict, and cache_ttl_seconds is
coerced defensively — so a malformed secrets: section can't abort startup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ord CLI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 1Password secret source resolves op:// references using OP_SERVICE_ACCOUNT_TOKEN read from os.environ. Under systemd the gateway gets that token via EnvironmentFile, but cron jobs, subprocesses, CLI runs, macOS launchd, and Docker containers spawn fresh interpreters with no inherited shell state — so they silently failed to resolve any reference and fell back to empty strings. Two patches close the gap, matching Bitwarden's reliability guarantees: 1. env_loader: auto-load ~/.hermes/.op.env after .env so the gitignored bootstrap token is available everywhere. override=False plus an explicit guard ensure it never clobbers a token already in env (e.g. from a systemd EnvironmentFile, which keeps precedence). 2. credential_pool: _get_env_prefer_dotenv() now prefers the resolved value in os.environ when .env still holds a raw op:// reference, instead of handing a URL to provider auth. Non-op:// values keep the existing .env-takes-precedence behaviour. Also gitignore .op.env, document the three bootstrap-token options, and add tests covering auto-load, no-override, and the resolved-vs-raw precedence (plus regression guards). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up on the cherry-picked NousResearch#36896 commits, wiring 1Password into the new registry as the reference *mapped* source: - OnePasswordSource adapter (shape=mapped, scheme=op): fetch-only — precedence, override semantics, conflict warnings, and env writes move to the orchestrator; apply_onepassword_secrets kept as legacy shim like Bitwarden's. - Registered in _ensure_builtin_sources; mapped op:// bindings now outrank bulk Bitwarden project dumps on contested vars. - _cache.py FetchResult/is_valid_env_name re-exported from base so there is exactly one canonical definition; bitwarden.py re-adapted onto the contributor's DiskCache substrate. - ErrorKind classification for op failures (auth/binary/empty/network). - Registry + conformance coverage for OnePasswordSource, incl. the headline multi-source test: both vaults claim the same var, mapped 1Password wins, conflict surfaced, provenance correct. - env_loader tests migrated off the legacy apply_* mocks onto the fetch layer; AUTHOR_MAP entry for @hwrdprkns.
A user-approved terminal/execute_code command could be SIGINT-killed (exit 130 + "[Command interrupted]") by a stale interrupt bit that landed on the execution thread during the blocking approval-wait, while the result still carried the "...approved by the user." note. The terminal tool runs sequentially inline on the execution thread, and nothing cleared or re-checked the bit between approval-grant and env.execute. Clear the current thread's interrupt bit once before an approved command spawns its child (terminal foreground; execute_code local + remote), and enrich the note to "...approved by the user, then interrupted." on a genuine post-start interrupt instead of implying success. A genuine interrupt arriving after execution starts (or during a retry backoff) still SIGINTs the command; non-approved commands keep current behavior. Adds regression tests covering stale-bit-clears, genuine-interrupt-still- kills, the retry-backoff window, natural-exit-130 (not mislabeled), and execute_code local + remote.
…esearch#59615) * feat(oneshot): add --usage-file JSON usage report to hermes -z Pipelines driving hermes -z (batch reviewers, cron scripts, eval harnesses) had no way to account for per-invocation spend: the agent computes estimated_cost_usd and full token counts internally, but oneshot mode discards everything except the final response text. - hermes -z PROMPT --usage-file PATH writes a JSON report after the run: estimated_cost_usd, cost_status/source, input/output/cache/ reasoning/total tokens, api_calls, model, provider, session_id, completed, failed. - Written even when the run fails (with a failure field) so callers can always account for spend; the write itself is best-effort and never masks the run's own outcome. - Flag registered in both the full parser and the Termux fast path; added to both value-flag scan sets so profile detection stays correct. Validation: 6 unit tests + live E2E (real -z run produced a report with real OpenRouter cost + token counts). * test: include usage_file kwarg in oneshot dispatch assertions The two dispatch tests assert the exact kwargs dict passed to run_oneshot; the new usage_file kwarg must appear there.
…sResearch#40069) Closes NousResearch#40069. Salvaged from NousResearch#40242; re-verified on main, tightened, tested. Co-authored-by: maxpetrusenkoagent <maxpetrusenkoagent@users.noreply.github.com>
Salvaged from NousResearch#40363; re-verified on main, tightened, tested. Co-authored-by: alex-heritier <alex-heritier@users.noreply.github.com>
…8/47 Salvaged from NousResearch#40273; re-verified on main, tightened, tested. Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
…less-no-web-build feat(cli): make hermes serve a real headless backend (no web UI build/mount, neutral ready sentinel)
Salvaged from NousResearch#40346; re-verified on main, tightened, tested. Co-authored-by: tjboudreaux <tjboudreaux@users.noreply.github.com>
…amps Salvaged from NousResearch#40303; re-verified on main, tightened, tested. Co-authored-by: pdmartins <pdmartins@users.noreply.github.com>
…e + 1Password sidebar fix (NousResearch#59613) * docs(secrets): secret-source plugin developer guide + sidebar registration for 1Password page - New developer-guide/secret-source-plugin.md: SecretSource contract (never raises/prompts, fetch-only, timeout budget), framework-vs-plugin ownership table, mapped-vs-bulk shape guidance, run_secret_cli() subprocess-safety, registration + timing note, conformance kit usage, ErrorKind reference. - Register user-guide/secrets/onepassword in the sidebar (page shipped in NousResearch#59498 but was not listed, so it was unreachable from nav). - Cross-link the user-guide plugin section to the new dev guide. * docs: group all plugin guides under a Plugins subcategory in Extending - Move guides/build-a-hermes-plugin.md -> developer-guide/plugins/index.md (both locales) and make it the category landing page (slug pinned to /developer-guide/plugins). - New sidebar subcategory Developer Guide > Extending > Plugins holding the general guide + all 8 provider-plugin docs (llm-access, memory, context-engine, secret-source, model, image-gen, video-gen, web-search); provider-doc URLs unchanged. - Client redirect /guides/build-a-hermes-plugin -> /developer-guide/plugins. - Update 30 cross-links across both locales.
The English-side rename from NousResearch#38138 already landed on main; this carries the remaining zh-Hans i18n catalog + doc-page rename so the localized docs match the skill's canonical name.
Adapts Claude Code's research-preview dynamic workflows (plan-in-code fan-out, hundreds of subagents per session) to Hermes invariants. The ported mechanic is plan/loop/intermediate-state-out-of-context, not more subagents. Documents the two real orchestration layers and the hard capability boundary between them: - Layer A (execute_code): deterministic fan-out, SANDBOX_ALLOWED_TOOLS only, cannot call delegate_task - Layer B (delegate_task batch): LLM-judgment fan-out Plus the synchronous trap (delegate_task is turn-scoped, cancelled on new message; durable/resumable = kanban swarm) and the genuinely-new piece: the adversarial-convergence verification recipe (N independent attempts with varied framings + M refuters, keep only located claims that survive refutation, iterate to convergence). Self-contained: inlines the load-bearing fan-out hygiene rather than hard-depending on local-only skills; references the shipped kanban swarm subsystem for the durable path.
Address all 5 review points against actual delegate_task behavior: - child toolsets are subject to delegate restrictions (leaf strips delegate_task/clarify/memory/send_message/execute_code), not 'full' - durable work has lighter options than kanban (cron one-shot, managed background terminal) for simpler cases - unique per-run /tmp/wf_<name>_<uuid> dir + freshness/count check so a stale interrupted run isn't read as success - note that one delegate_task batch is capped by delegation.max_concurrent_children; large fan-out needs bounded waves - delegate_task exposes no per-task model/profile field (per-task keys are goal/context/toolsets/role); model/profile-scoped runs go via delegation config, cron, kanban, or separate process
Surfaces the usage_report()/provenance() data layer added in NousResearch#36701 as a user-facing CLI command. Unlike `hermes curator status` (scoped to curator-managed agent-created candidates), `usage` lists every skill on disk — bundled built-ins and hub-installed included — with per-skill use/view/patch counts and an agent/bundled/hub provenance tag. Flags: --sort {activity,recent,name}, --provenance {agent,bundled,hub} filter, --json for machine-readable output.
…NousResearch#59817) - New developer-guide/browser-provider-plugin.md: BrowserProvider ABC (session lifecycle, CDP contract, bb_session_id back-compat key, raise/never-raise split between create and close/cleanup), get_setup_schema() hermes-tools integration, discovery, checklist. Closes the one gap in the provider-plugin family — the ABC and ctx.register_browser_provider() existed with zero docs. - Register the page in the Plugins sidebar subcategory. - Extend the routing map on the Plugins landing page (both locales) with the previously missing rows: web-search, browser, secret-source, and dashboard-auth surfaces.
The 'CI timing report' job is pure observability — it collects per-job/step durations from the GitHub API after the run and publishes an HTML gantt report + PR-vs-main timing diff. It gates nothing (all-checks-pass does not include it), yet it could redden a PR: the script makes dozens of paginated API calls with the shared repo GITHUB_TOKEN and had zero retry handling, so a single 403 (rate-limit burst when several PRs run CI concurrently) failed the job. Observed twice in a row on PR NousResearch#59805. - api_get(): retry 403/429/5xx and connection errors with exponential backoff, honoring Retry-After / X-RateLimit-Reset (max 5 attempts, 120s cap). Non-transient statuses (404 etc.) still fail fast. - main(): exhausted retries raise TimingsUnavailable, caught to emit a degraded summary line + placeholder HTML artifact and exit 0 — a metrics collector must never fail the PR's checks. No timings JSON is written on the degraded path so an empty baseline can never be cached. - ci.yml: baseline-save steps on main skip gracefully when no JSON exists. Verified with a mocked urlopen harness: retry-then-success (3 attempts), exhausted-retries -> TimingsUnavailable, 404 fails fast without retry, degraded main() exits 0 with summary + placeholder and no JSON, and the --from-json happy path is unchanged.
NousResearch#60703) (NousResearch#60855) Three fixes for the silent post-restart ticker stall: 1. _jobs_lock() bounds its cross-process flock: LOCK_NB polled against a 30s deadline instead of an unbounded LOCK_EX taken while holding the process-wide RLock. On timeout it logs at ERROR and degrades to in-process-only locking (the existing fallback path), so a sibling process wedged while holding .jobs.lock can no longer freeze every cron function - including the ticker's get_due_jobs() and thus the heartbeat - forever with zero logging. 2. fire_claim/run_claim freshness checks are bounded on both sides (0 <= age < ttl): a claim stamped in the future (clock/TZ skew across a restart) was previously fresh forever, making the job permanently unfireable and every manual run report 'already being fired'. 3. _execute_job_now distinguishes paused/disabled/missing jobs from a genuinely held claim instead of mislabeling them all as 'already being fired'.
The busy-session branch of _notification_poller_loop re-queued the completion event and immediately re-polled it with no sleep, spinning at full speed (100% CPU, ~1100 futex/s of GIL churn) for as long as the session stayed running. This starved the dashboard asyncio loop: /api/status went from 0.14s to 3-6s with 10s timeouts. Sleep 0.25s outside history_lock before re-polling, mirroring the 0.1s back-off already used for foreign-session events.
The desktop app's chat panel reuses tui_gateway as its backend, so every chat session was stamped platform="tui". That made the agent read terminal-specific platform guidance while running in the graphical desktop chat surface. Resolve the misclassification at its source: tui_gateway now picks platform="desktop" when HERMES_DESKTOP=1 and HERMES_DESKTOP_TERMINAL is unset, and keeps platform="tui" for the embedded terminal pane and standalone TUI. Add a PLATFORM_HINTS["desktop"] entry describing the actual chat surface (full GFM markdown, MEDIA: intercept, inline images). Move the embedded-pane clarifier to the platform-hint resolution site so it appends only to the tui hint under HERMES_DESKTOP_TERMINAL=1. Delete the now-dead desktop-hint block from build_environment_hints() that competed with the platform hint. Standalone TUI sessions produce byte-identical prompts as before; the new desktop hint and clarifier are assembled once per session in the stable tier, so prompt caching is preserved.
Carry the live TUI session id with async delegation completion events and prefer the commissioning UI session when desktop pollers share the completion queue. Resolve compressed session keys to their continuation before treating events as orphaned, and capture the live parent agent session id for TUI/ACP dispatch.
…ion lifecycle Two invariants layered on the origin-routing commit (NousResearch#55578): 1. Fail closed on orphaned async-delegation payloads. The poller's belongs-elsewhere check handles events owned by another LIVE session, but an event whose owner is gone previously fell through and was adopted by whichever poller saw it - injecting one chat's delegation output into another chat. Delegation completions are now injected only into a session that PROVABLY owns them (origin UI id, or session-key/lineage match via the compression chain); unowned payloads are dropped from injection with a WARNING (the subagent's output is already persisted in the delegation records, so nothing is lost). The shutdown drain applies the same rule. Non-delegation events keep the historical adopt-orphans behavior. 2. A session's in-flight async delegations end with the session. _finalize_session now calls interrupt_for_session(): delegations commissioned by the closing UI session are interrupted always; key-matched delegations only when the TUI owns the session lifecycle, so closing a viewer tab on a live gateway session never kills the gateway's own background work.
…op owl-alpha (NousResearch#60943) - OPENROUTER_MODELS: remove openrouter/owl-alpha (free) and tencent/hy3-preview{,:free}; add tencent/hy3 and tencent/hy3:free - _PROVIDER_MODELS[nous]: tencent/hy3-preview -> tencent/hy3 - run_agent.py reasoning-prefix list: tencent/hy3-preview -> tencent/hy3 (prefix match still covers -preview if pinned) - model_metadata: register hy3 context length (262144) alongside hy3-preview - regenerate website/static/api/model-catalog.json - update tokenhub curated-list tests to the new IDs The tencent-tokenhub direct provider still serves hy3-preview and is intentionally unchanged.
… range (3.11-3.13)
… range (3.11-3.13)
…g session The completion event already carries the dispatching session's session_key (captured at dispatch time in delegate_tool.py:2798), but the delivery router ignored it — results landed in whatever session was active at completion time instead of the session that dispatched the subagent. Changes: - drain_notifications() in process_registry.py: optional session_key filter. Non-matching async_delegation events are re-queued instead of consumed, so they remain available for the correct session's drain. - cli.py process_loop: passes active session_key to drain_notifications() - tui_gateway/server.py post-turn drain: passes session_key from the TUI session dict - gateway/run.py _build_process_event_source: logs warning when routing metadata is unresolvable (previously silent drop) - Regression tests verifying session-scoped drain filtering Fixes NousResearch#58684
Extends the salvaged session_key filter with the same fail-closed, compression-chain-aware ownership gate the poller uses (NousResearch#55578): - drain_notifications() accepts an owns_event callback; when provided, an async-delegation event is consumed ONLY on positive proof of ownership, and a broken callback re-queues (never leaks). Bare key equality remains for single-session callers (CLI); no filter remains legacy behavior. - The TUI post-turn drain passes _session_owns_notification_event, so it can't adopt another session's (or an orphan's) delegation payload, while a post-compression session still claims its own pre-compression dispatches - the gap bare key equality left open.
…esktop can invoke it
…nd.dispatch Ported from NousResearch#60834 (same author) — pending-input routing so clients that fail the slash.exec->dispatch fallback still reach the new compress handler.
…dow flash on Windows
…NousResearch#53009) In single-query (-q) mode, the assistant's final answer was printed and then immediately erased by _print_exit_summary() — which unconditionally called _clear_terminal_on_exit() (ESC[3J ESC[2J ESC[H]). The answer was present in the session store but invisible in the terminal. The clear is only needed for interactive TUI teardown (NousResearch#38928) where prompt_toolkit chrome must be cleaned up. Add a clear_screen parameter to _print_exit_summary() (default True, preserving interactive behavior) and pass False from the single-query call site so the answer stays visible above the exit summary. Regression tests cover: - clear_screen=True (default) calls _clear_terminal_on_exit() - clear_screen=False skips the clear - Single-query -q path passes False end-to-end - Interactive path still clears (preserving NousResearch#38928)
…esearch#57498) Background delegate_task completions only carried session_key. When multiple active sessions shared a routing peer, get_or_create_session could recover the latest ended_at IS NULL row and inject the subagent result into the wrong session. Capture parent_agent.session_id at dispatch time, include it on async-delegation completion events, and pin gateway routing via switch_session when the synthetic completion message is handled. Fixes NousResearch#57498
…ns; /new severs in-flight delegations Completes the session-binding class on the gateway surface (NousResearch#55578), matching the TUI rules: 1. Fail-closed pinning: switch_session() re-opens ended sessions, so pinning a completion to a spawning session that has since ENDED (user /new, closed rotation) would resurrect a conversation the user explicitly ended and inject into it. The injection path now checks the pinned row's ended_at first and drops the injection with a WARNING when the spawning session is dead or unknown - the result stays in the delegation records. 2. /new ends the old conversation's delegations: _handle_reset_command calls interrupt_for_session() with the expiring durable session id (matching the parent_session_id pin stamped at dispatch) plus the routing key as fallback, so a reset can't leave dangling subagents whose completions have no live owner. interrupt_for_session() gains the parent_session_id selector because a gateway chat's session_key (the platform conversation key) survives a reset while the session id rotates - key-based matching alone could never sever a gateway conversation's delegations.
…AP entry - run_route_script shells out with subprocess.run (up to 30s timeout); wrap the call in asyncio.to_thread so a slow script can't stall every other webhook and gateway task on the loop. - scripts/release.py: map grace@weeb.onl -> evelynburger for the salvaged contributor commit.
… a new one (NousResearch#55578) (NousResearch#60874) Two client-side halves of the NousResearch#55578 session split: 1. Submit with a null activeSessionId but a SELECTED stored session now resumes that stored session instead of falling straight through to createBackendSessionForSend - which silently forked the user's conversation into a brand-new session that then got orphan-reaped. New-chat drafts (no stored selection) still create sessions as before. 2. prompt.submit recovery now also fires on gateway request timeouts, not only 'session not found'. A starved backend loop (the async- delegation poller spin) rejects the submit with 'request timed out' even though the stored session is fine; previously that surfaced an error, left the binding cleared, and set up the split on the next send. Fail-then-pass: 2 new tests fail with production code reverted.
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
Estimate tool-schema size without repeatedly stringifying full tool lists, and cache the result per tool snapshot to reduce GIL-heavy work during preflight and compaction.
Adds a regression test that repeated request-token estimates do not re-serialize the same tool schema list.
b382b12 to
4380ac9
Compare
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
…check (#5) The sync _session_has_compression_in_flight sat on the message hot path and blocked the event loop twice: under session_store._lock during _ensure_loaded_locked (JSON read) and via db.get_compression_lock_holder (SQLite SELECT). Async-ify the method and offload both sources via asyncio.to_thread; await the call site in _handle_active_session_busy_message.
Summary
len(str(tools))estimate with a cached, field-based estimate over tool schemas.Why
User reports (Win11 Desktop) show long "Summarizing thread" timers and eventual UI death. Logs contain repeated
event loop stalled ... (GIL pressure suspected)andws write slow, consistent with CPU/GIL pressure in backend during compaction.Changes
agent/model_metadata.py: cache tool-schema rough token estimate per tool snapshot; avoid repeated full tool-list stringification.tests/agent/test_model_metadata.py: regression test ensures repeated estimates don’t re-serialize tool schemas.Test plan
scripts/run_tests.sh tests/agent/test_model_metadata.py -qMade with Cursor