Add a non-terminal "review" status so a worker that finished implementation
can hand off for human review without abusing kanban_block. The old
kanban_block(reason="review-required: ...") convention routed the handoff
through the unblock-loop breaker, so a normal review -> changes -> review
cycle was falsely escalated to triage.
- kanban_db: request_review (running/ready -> review, non-block, emits
review_requested), reopen_review_task (review -> ready/todo, review_reopened),
complete_task accepts review -> done, and a review_dispatch gate (default off,
shared by the dispatcher loop and the gateway health probe).
- kanban_request_review worker tool + `request-review` / `reopen-review` CLI
verbs; tool wired through toolsets, EXPOSED_TOOLS, _POLISHED_TOOLS.
- Gateway notifier wakes the origin subscriber on review_requested and
block_loop_detected; the subscription survives until done/archived, so every
review cycle re-notifies.
- Dashboard PATCH + bulk route the review transitions (request_review /
reopen_review_task) and render the review column.
- goals.py goal-loop and KANBAN_GUIDANCE recognize review as a terminator.
- Docs (reference tables, user guide, AGENTS.md, zh-Hans mirrors) + tests.
needs_input / failed are unchanged: they still route through kanban_block,
still count toward block_recurrences, and still escalate to triage.
Titling is two-stage — a slice of the user's own words lands inline, the
model's version replaces it a second later — and the platform rename lanes
fired on both. That is two rate-limited calls to reach one name, and
Discord allows two channel renames per ten minutes, so the throwaway could
be the one that survived. The callback now carries which stage it is, and
the lanes take the model's.
The relay lane also asked where the reply landed at title time, which is
before the model has answered: it polled the send-result cache for ten
seconds and read the timeout as "never auto-threaded", so any turn with
tool calls in it silently kept its raw thread name. Wait on the send
itself instead — the adapter already owns that cache, so it can say when a
reply arrives and, just as usefully, that one arrived carrying nothing.
Titling fired on the first response, so a session sat unnamed for the whole
opening turn - p50 151s, p90 1212s across real sessions, because a turn is
tool calls, not one round-trip. A turn that failed or was interrupted never
got a title at all. Four surfaces each carried their own copy of the call.
Move it into the shared turn prologue and split it in two: a deterministic
title derived from the user's opening message, written inline before the
model runs, then one small-model call that upgrades it. The response is
constrained to a JSON object so there is no preamble to strip, and control
wrappers are stripped rather than refused, so a slash command titles as
what the user asked for instead of the command itself.
Closes the last two emission gaps from #77484:
- tools/terminal_tool.py: both exception paths (generic except and
TERMINAL_DEGRADED_MODE=fail) returned raw str(e) + traceback.format_exc()
to the model — only the logger copy was redacted. Exception text can
embed the failing command line and any secrets inline in it; both fields
now pass through redact_sensitive_text.
- acp_adapter/entry.py: _setup_logging cleared root handlers and installed
a plain logging.Formatter, bypassing redaction entirely on ACP stderr.
Now uses RedactingFormatter like every other logging surface.
The other three gaps from #77484 (process(list), *_KEY regex variants,
control-char splits) were fixed in #80964/#80965.
- Give cached_fetch_api_models the same stale-while-revalidate tier as
cached_provider_model_ids: TTL-expired entries within the 7d window are
served instantly while a background refresh rewrites the cache —
without this, every /model open an hour into the session re-blocked on
the live probe (#72762's stall class, deferred).
- Generalize _spawn_swr_refresh(cache_key, refresh_fn) so non-slug
custom:<base_url> keys reuse the same inflight-dedupe scaffolding;
slug behavior unchanged (default refresh_fn preserved).
- Convert the missed sibling site: acp_adapter/server.py
_named_custom_provider_catalogs() live-probed every custom_providers
row's /v1/models per ACP catalog build.
- Extract _cache_entry_valid() (the fp/models predicate existed 4x) and
validate 'at' is numeric so hand-edited/corrupt cache JSON degrades to
a live fetch instead of raising through the picker's blanket except.
- Flatten the dead api_mode conditional (fetch_api_models declares
api_mode=None; branch was behaviorally inert).
- Tests: 4 new guards (stale-serve, stale-window cutoff, generalized SWR
write-through, corrupt-at degradation) — stale-serve and corrupt-at
mutation-checked; 2 existing tests updated for the new behavior.
#80216 fixed /retry (and a follow-up fixed yuanbao recall) destroying
soft-archived active=0/compacted=1 in-place-compaction rows via the
destructive replace_messages default. Two sibling sites still carried the
same class:
- acp_adapter/session.py _persist (non-owned-agent branch): probed
has_archived_messages and FAILED OPEN into the destructive full replace
on any probe error; the probe can also race a concurrent
archive_and_compact. Now passes active_only=True unconditionally — on a
fresh create/fork every row is active=1 so behavior is identical, and
the probe (its only production caller) is deleted.
- tui_gateway/methods_prompt.py edit/regenerate truncation: bare
replace_messages() deleted the archived transcript of a compacted
session on every edit/regenerate. Now active_only=True.
hermes_state.has_archived_messages docstring updated (probe is now
test/diagnostic-only). Test stubs in test_tui_gateway_server.py accept the
new kwarg. New regression tests: real-SQLite archive-survival for both
write shapes, fresh-session equivalence (the claim the unconditional
switch rests on), and source-level guards pinning that neither site
re-grows the fail-open probe (both mutation-checked: revert either fix and
its guard fails).
When an approval prompt expired without a response, every CLI-side path
collapsed the timeout into the same 'deny' choice as an explicit user
refusal, so the agent was told the user denied the action when the user
simply never answered. The gateway wait already distinguished the two
('timed out without user response... Silence is not consent.'); this
brings the CLI/TUI/ACP surfaces to parity.
- prompt_dangerous_approval(): input()-path expiry now returns a distinct
'timeout' choice (still fail-closed).
- cli.py _approval_callback + hermes_cli/callbacks.py approval_callback:
deadline expiry returns 'timeout' instead of 'deny'.
- check_all_command_guards / _run_approval_gate CLI tails: 'timeout' maps
to outcome='timeout' with a 'timed out without user response... Silence
is not consent.' BLOCKED message (matching the gateway wording);
explicit deny keeps outcome='denied' and gains user_consent=False for
shape parity.
- computer_use: 'timeout' verdict threads through the CLI adapter and
yields a 'prompt timed out — the user did not respond' error instead of
'denied by user'.
- ACP permissions bridge: FutureTimeout returns 'timeout' (other failures
still 'deny'); elicitation maps 'timeout' to 'cancel' like the gateway's
unresolved outcome; codex wire mapping documents deny/timeout→decline.
- write_approval already treats unknown choices as 'stage, not drop', so
a timeout now stages the memory write instead of silently refusing it.
Every timeout path remains fail-closed — the action never runs; only the
classification reported to the agent changes.
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
Makes interrupt-protected context compression cancellable by an explicit
user or lifecycle stop, without weakening protection against ordinary
incoming messages, voice interjections, or active-turn redirects.
Separates explicit hard cancellation from ordinary interrupt/redirect
state with a dedicated threading.Event; introduces
AuxiliaryExplicitCancellation as an attempt-local frozen-cause signal;
isolates the synchronous provider callback in a bounded daemon worker
during protected compression; atomically linearizes Codex timeout
cleanup against explicit cancellation; propagates hard cancellation
through child agents and explicit stop surfaces; serializes hard-cancel
admission against compression commit admission with
CompressionCommitFence; aborts before session rotation or late DB commit,
restores in-place transcript mutations and compressor state, and releases
the heartbeat and compression lease.
Based on #74449 by @suparious. Resolved merge conflicts in
agent/context_compressor.py (feasibility check + try/except) and
tui_gateway/methods_session.py.
The ACP prompt path called set_session_vars(session_key=session_id, ...)
without passing session_id, so the HERMES_SESSION_ID ContextVar was bound
to its explicit "" default. Once the session-context machinery is engaged,
_inject_session_context_env treats an explicitly-bound "" as authoritative
and writes it to the child env — so subprocesses spawned during an ACP turn
got an empty HERMES_SESSION_ID instead of the session's own id.
Pass session_id through so child subprocesses carry the correct id.
Salvage of the ACP half of #53454 by @necoweb3 (the V4A-path half is
salvaged separately in the file-tools PR).
Co-authored-by: necoweb3 <sswdarius@gmail.com>
The claim 'late tools then require an explicit /reload-mcp' was false on
current main: the between-turns prologue refresh (agent/turn_context.py)
picks up late-connecting servers cache-safely at every turn boundary,
and ACP has no /reload-mcp. The daemon's real marginal value is tool-list
freshness in the [session created -> first message] window.
Review follow-ups on the #32811 salvage:
- Hold state.runtime_lock and bail on is_running so the pre-first-turn
guard can't race the first prompt dispatch (a refresh publishing
mid-turn would swap tools= and break the just-created cache prefix).
Regression test mutation-checked (guard removed -> test fails).
- In-memory-only session lookup in the daemon: get_session() falls
through to a DB restore that builds a whole new AIAgent just to
decide no-op (TUI equivalent also checks its in-memory dict only).
- Pass quiet_mode=True explicitly, matching the TUI/gateway callers.
- Use ensure_mcp_discovery_before_agent_build() (landed on main after
the PR) instead of bare wait_for_mcp_discovery() so the ACP agent
build is self-sufficient and gets the retry-after-zero-connected
allowance, matching CLI/one-shot construction sites.
ACP entry.py fires MCP discovery in a background daemon thread, but
_make_agent snapshots tools once at build and never re-reads the registry.
Unlike CLI/TUI, ACP had no bounded wait before the snapshot and no
late-refresh for configured (config.yaml) MCP servers — a reachable-but-
slow server that finished after agent build was invisible for the whole
session.
Changes:
- acp_adapter/session.py (_make_agent): call wait_for_mcp_discovery()
before AIAgent construction, bounded by mcp_discovery_timeout (default
~1.5s). A dead server can't block; servers that miss the bound are
picked up by the late-refresh below.
- acp_adapter/server.py (_schedule_mcp_late_refresh): new method on
HermesACPAgent — if discovery is still in flight after session creation,
spawns an off-critical-path daemon that joins it (bounded 30s), then
rebuilds the tool snapshot via the shared refresh_agent_mcp_tools helper.
Cache-safe: only runs pre-first-turn (_user_turn_count/_api_call_count
both 0); once the user has sent a message the snapshot is frozen, exactly
as TUI PR #48403 does.
- Called from new_session, load_session, resume_session.
- Mirrors the TUI pattern (tui_gateway _schedule_mcp_late_refresh, PR
#48403) and the CLI pattern (get_tool_definitions → wait_for_mcp_discovery).
Tests:
- Replace the AST-based test (source-text inspection) with three
behavioral regression tests in tests/acp_adapter/test_acp_mcp_discovery.py:
1. Blocked discovery does not block startup (non-blocking contract)
2. Delayed discovery lands tools via late-refresh (pre-first-turn)
3. Late-refresh is cache-safe: skips rebuild after first turn
Addresses teknium1 review on PR #32811.
Fire-and-forget MCP server connections on a daemon thread so the
gateway / CLI / ACP process becomes interactive immediately instead
of blocking on slow remote MCP servers (HTTP timeouts, sluggish
stdio boot). Previously `hermes --tui` waited 2-5 s after the splash
screen before rendering the UI while `discover_mcp_tools()` ran
synchronously on the critical path.
Changes:
- tools/mcp_tool.py: add `discover_mcp_tools_background()` — thin
wrapper that spawns `discover_mcp_tools()` on a named daemon thread
- tui_gateway/entry.py: call `discover_mcp_tools_background()` before
sending gateway.ready (replaces inline call that blocked the JSON-RPC
pipe for the TUI Ink app)
- hermes_cli/main.py:
- skip `\_prepare_agent_startup()` for TUI path — plugins, MCP, and
shell hooks are only needed by the CLI agent loop; the TUI's
gateway subprocess discovers them independently (~370 ms saved)
- fast-path in `\_make_tui_argv()`: when `dist/entry.js` exists and
is fresh, skip npm install / rebuild checks entirely (~350 ms saved)
- cli.py (`\_prepare_deferred_agent_startup`): same background pattern
for deferred startup (Termux interactive CLI)
- acp_adapter/entry.py: same pattern so ACP server launches asyncio
immediately while MCP connects in parallel
Result:
- TUI Python wrapper: ~730 ms → ~80 ms (9× faster)
- gateway.ready: ~2700 ms → ~400 ms (7× faster)
- Total TUI cold start: ~3400 ms → ~480 ms
Related: #29726, #29184, #19326 (closed stale)
Closes#29726
find_custom_provider_identity_by_model (runtime_provider.py:895,908) and
acp_adapter/server.py:149 still used the old f"custom:{_normalize_custom_provider_name(...)}"
pattern while the rest of the codebase migrated to custom_provider_slug.
For keyed providers whose display name differs from their config key, the
model-based reverse lookup would return custom:<display-name> instead of
the stable custom:<provider_key> identity every other code path returns.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Slash commands run on the event-loop thread, outside the per-turn
contextvars.copy_context() that pins the session cwd for the agent call.
/compress reaches agent._build_system_prompt(), whose "Current working
directory" line comes from resolve_agent_cwd() — so an unpinned handler
rebuilt the prompt against the Hermes install tree and PERSISTED it as
the session's cached prompt, re-poisoning every later turn even though
the turn itself is now pinned.
Pin inside a fresh context copy so the write cannot leak into other
concurrent ACP sessions on the shared loop and needs no teardown.
An ACP session registered the client's cwd for the *tools*
(`_register_task_cwd` -> `register_task_env_overrides`) but never pinned it
for the *prompt*. `agent/prompt_builder.py` reports
`Current working directory: {resolve_agent_cwd()}`, and `resolve_agent_cwd()`
reads the `_SESSION_CWD` contextvar, which ACP left unset — so it fell back
to `TERMINAL_CWD` / the launch dir.
The system prompt therefore advertised one root (commonly
`~/.hermes/workspace`, or the install tree) while the tools were rooted at the
editor's project. When the model emitted a *relative* path the tools resolved
it correctly; when it emitted an *absolute* path built from the advertised
root, the write landed outside the client's workspace and the turn still
reported success.
Observed in Zed/Buzz-shaped usage: the first prompt in a fresh workspace
creates the file under `~/.hermes/workspace/` and answers "Done." The client's
directory is untouched. Later sessions on the same cwd appear to work once a
session cwd record exists, which makes it look intermittent.
`_run_agent` already calls `set_session_vars(session_key=session_id)` inside
its `contextvars.copy_context()`, and that helper's `cwd` argument exists to
"pin the logical working directory for this context". It simply was not
passed. `gateway/session_context.py` and `tui_gateway/server.py` both already
pass it; ACP was the only surface that did not.
Adds a regression test asserting that the resolved cwd *during the turn* is
the cwd the client passed to `session/new`. It fails without this change
(resolving the install tree instead of the client's project).
Named endpoints from the providers: mapping (and legacy custom_providers:
list) never appear in the ACP model selector: _build_model_state lists
only the canonical current provider's catalog, and canonical provider
enumeration does not include user-defined named endpoints. The TUI
/model picker already renders these entries (#47039, implemented for the
TUI surface only), so editor clients silently hide endpoints the user
configured — e.g. an OpenAI-compatible Bedrock Mantle Responses provider.
Add _named_custom_provider_catalogs(), sourcing entries from
get_compatible_custom_providers() (covers both config shapes), and append
its models to the selector payload. Choice ids use the custom:<name>
slug shape so custom:<name>:<model> selections round-trip through
parse_model_input / resolve_runtime_provider unchanged on set_session_model.
Declared models (default_model + models) survive failed live /models
discovery — some OpenAI-compatible endpoints expose no /models route yet
serve their declared models fine. Honors providers.<name>.enabled: false
and discover_models: false.
Verified: scripts/run_tests.sh tests/acp/ — 318 passed, 0 failed;
scripts/check-windows-footguns.py clean.
ACP clients (Zed, Buzz) render the whole availableModels array in a single
dropdown, so requesting the shared inventory with max_models=None could
hand an editor an unbounded cross-provider catalog.
Request the same per-provider cap the MoA picker already uses
(hermes_cli/moa_cmd.py), exposed as ACP_MAX_MODELS_PER_PROVIDER so the
intent is documented at the call site.
This bounds each provider's row rather than the total, matching the shared
inventory's own semantics: aggregator providers stay intentionally
uncapped, and the existing current-model fallback still re-inserts a
selection that falls outside the cap. At present no authenticated provider
approaches 200 models, so the visible catalog is unchanged; the cap is a
guardrail for large catalogs (e.g. OpenRouter) rather than a change to
today's lists.
The new test asserts the contract - bounded row plus a reachable current
selection - instead of a fixed catalog size, so growing the inventory
cannot turn it into a change-detector.
Co-authored-by: amanning3390 <adam.manning@pro-serveinc.com>
Signed-off-by: amanning3390 <adam.manning@pro-serveinc.com>
.get("key", {}) only applies the default when the key is ABSENT.
When the key exists with value None (null in JSON), .get() returns
None and the subsequent .get() raises AttributeError.
Fix: replace .get("key", {}).get(...) with (.get("key") or {}).get(...)
which handles both missing keys AND None values.
8 instances across 6 files:
- gateway/run.py: tool_call function name check
- acp_adapter/server.py: tool name/description extraction
- gateway/platforms/qqbot/onboard.py: API response task_id
- gateway/platforms/yuanbao.py: message content parsing (x2)
- gateway/platforms/slack.py: block text extraction (x2)
- tui_gateway/server.py: error message extraction
Main renamed the ACP slash command from /compact to /compress after #63630
was written; update the salvaged status line, comment, and behavioral test
to dispatch the command that actually exists.
compression.enabled: false is documented (agent/conversation_loop.py
overflow path) as disabling *automatic* compaction only — the terminal
context-overflow error explicitly tells users to run /compress manually,
and the gateway handler has never gated on the flag. But the classic CLI
(_manual_compress) and the ACP adapter (/compact) refused with
'Compression is disabled', leaving users at a full context with no
manual escape hatch on those surfaces.
Remove the stale gates (they predate the overflow-path design; the CLI
gate came from the original /compress commit's boilerplate) and unify
force=True across all manual-compaction call sites: ACP /compact and the
TUI's _compress_session_history (manual-only helper) now bypass the
summary-failure cooldown exactly like the CLI and gateway already did.
Also reword the ACP /context status line so a disabled-compression agent
no longer implies /compact is unavailable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A context-compaction handoff is persisted as an ordinary history message
but is not a real turn. The ACP history replay streamed it as a bare
user/agent message chunk, dropping the in-process _compressed_summary
marker, so ACP frontends (editors, vscode-hermes) rendered the entire
handoff as a regular message.
Tag replayed summary chunks under _meta.hermes (ACP's extensibility
channel), covering all three persistence shapes the compressor emits:
- standalone role="user" handoff -> compactionSummary: true
- standalone role="assistant" handoff (alternation-driven role pick)
-> compactionSummary: true
- merge-into-tail message (preserved tail content + appended summary)
-> containsCompactionSummary: true, a distinct key so clients that
collapse standalone summaries cannot hide the preserved real content
Detection honors the in-process metadata flag and falls back to a new
ContextCompressor.classify_summary_content() content classifier
(standalone/merged/None), so it also works for a DB-reloaded session
that lost the in-memory flag. _is_context_summary_content is now a thin
wrapper over the classifier, keeping existing callers unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes Homebrew and PyPI wheel/sdist as Hermes distribution paths while
preserving the supported source, Docker, and Nix workflows.
Changes:
- Removes the Homebrew formula, PyPI publish workflow, sdist manifest
(MANIFEST.in), and wheel/sdist release-attachment logic from scripts/release.py.
- Keeps setuptools metadata and entry points required by editable installs
and Docker/Nix builds, but adds a setup.py guard that rejects wheel/sdist
builds outside a sealed Nix derivation (HERMES_NIX_BUILD=1).
- Removes pip/Homebrew install detection, PyPI update checks, the pip
self-update path, the deprecation-banner state, the postinstall subcommand,
wheel data-directory fallbacks in agent/i18n.py and hermes_constants.py,
and the ACP Registry manifest/version-lockstep release logic.
- Adds /nix/store/ path detection so `nix run` / `nix profile install`
installs (which don't set HERMES_MANAGED) are correctly identified as
"nix" rather than falling through to "git"/"unknown".
- Retired install-method values ("pip", "homebrew") in existing
.install_method stamps (both code-scoped and home-scoped) are ignored by
the allowlist reader and fall through to "unknown" instead of resurrecting
a retired enum value.
- Updates Nix packaging to ship bare runtime data (locales, optional-mcps)
through store symlinks and wrapper env vars instead of wheel data-files.
- Removes the ACP Registry manifest/icon and their version-lockstep tests.
- Deletes or rewrites packaging, pip-update, Homebrew, and ACP Registry
tests; adds parametrized coverage for the packaging build guard covering
BOTH sdist and wheel paths (the guards live in separate cmdclass entries
— a passing sdist test proves nothing about the wheel path).
- Updates installation/platform documentation and related user-facing copy.
- Adjusts the supply-chain scan so deleted install-hook files do not trigger
a finding, while additions or modifications still require the existing
ci-reviewed label gate.
Supported installation paths (unchanged):
- git installer (install.sh)
- Docker
- Nix/NixOS
- editable development installs (uv sync, uv pip install -e ., pip install -e .)
The default `busy_input_mode: interrupt` now redirects the live turn instead
of hard-stopping it and re-queuing a fresh turn, wired consistently across
every first-party surface via the shared core primitive.
- CLI, gateway (busy + PRIORITY paths), TUI (`_handle_busy_submit`), desktop
(`session.redirect` RPC), and ACP call `redirect()` when the agent advertises
`_supports_active_turn_redirect`, and fall back to the proven interrupt +
next-turn queue for older runtimes.
- Redirect is gated to plain text with no attachments: captioned or
attachment-bearing events (including adapters that classify unknown media as
`TEXT`) stay queued so media is never dropped.
- ACP `cancel()` records the interrupted prompt, sets its cancel event, and
hard-stops the agent while holding `runtime_lock`, closing the
cancel-then-correct ordering gap; connection I/O happens after the lock is
released.
- Desktop appends the correction as a real user transcript message so the live
view matches the durable history after reload.
- `/busy` help, onboarding hints, and the new `session.redirect` RPC describe
the redirect behavior; `/stop` remains the hard stop.
- acp_adapter/server.py: rename compact -> compress for context compression command
- tui_gateway/server.py: rename /compact -> /density for display density toggle
- ui-tui/core.ts: rename compact -> density for display density toggle
- Internal config keys (tui_compact) and UI state (ctx.ui.compact) unchanged
Follow-up to the restore-boundary alternation heal (#65492): get_messages_
as_conversation grew a repair_alternation flag, wired into gateway
load_transcript and the CLI startup resume. Three other LIVE-REPLAY
restore sites still loaded the transcript verbatim, so a durable
'user;user' violation there re-fires the pre-request defensive repair on
every request for the rest of the session (it only ever mutates the
per-request list, never the restored working conversation):
- acp_adapter/session.py::SessionManager._restore — the loaded history
becomes the resumed ACP (Zed) agent's SessionState.history.
- hermes_cli/cli_commands_mixin.py — the /resume slash command sets
self.conversation_history from the load (the startup resume was fixed,
this mid-session one was missed).
- tui_gateway/server.py — the resume handler feeds the load into the
deferred session record's working conversation.
Pass repair_alternation=True at all three so the wedge is healed once at
restore. Inspection/export consumers (trace upload, context guard,
api_server history, display_history) keep the verbatim default.
Adds an end-to-end regression test driving the ACP _restore path: a
seeded user;user session restores to an alternation-clean live history
with no user input lost.
Add a runtime_validator callback to generate_title() / auto_title_session()
/ maybe_auto_title(). Callers snapshot the session's model+provider when
spawning the background titler; the validator runs right before the LLM
request and skips it silently when the live runtime no longer matches —
so a stale title request can't reload a model that strict_single_load
already evicted after a user model switch. Fail-open: a raising validator
never disables titling.
Wired at all four call sites (cli, gateway, tui_gateway, acp_adapter).
Surgical reapply of PR #19137 (base was 8k+ commits stale; the original
patch predates the pinned-language prompts, the atomic-write helper, and
the moved TUI/ACP call sites). Original work by @Thatgfsj. Closes#19027.
build_tool_start renders every ACP (Zed) tool call — on the live tool-progress
callback (acp_adapter/events.py) and during session history replay
(acp_adapter/server.py). It called build_tool_title and extract_locations
directly, so a model that emits a malformed argument crashed the render:
- terminal `command` as null/number -> TypeError (len() in build_tool_title)
- delegate_task `goal` as a number -> TypeError (len())
- read_file `path` as a non-string -> pydantic ValidationError building a
ToolCallLocation
A live crash breaks the tool-call event; a persisted one breaks history replay
on every resume of that session. The sibling CLI label builder
get_cute_tool_message was already wrapped for exactly this reason
(agent/display.py: "display must never abort a turn").
Wrap build_tool_start the same way: on any builder failure, fall back to a
minimal, valid start event (tool name as title, resolved kind). The happy path
is unchanged.
Adds tests for the non-string command, path, and goal cases.
Add shared translators in hermes_constants (Windows drive → /mnt, `\\wsl(.localhost|$)\`
UNC → POSIX, gated on is_wsl) and apply them at the gateway session-cwd boundary
so a Windows-host UI can hand the WSL backend a path it can actually chdir into.
De-dups the ACP adapter's private `_win_path_to_wsl` onto the shared helper and
extends it to the UNC spelling.
Co-authored-by: Rage Lopez <VrtxOmega@pm.me>
ruff check --fix --select F541 . on current main. Pure prefix removals;
adjacent-string concatenations keep the f only on interpolating fragments.
No string content or live placeholder altered.
Follow-up widening the archived-history fix to the sibling save paths the
original PR did not cover. Model switches (_cmd_model, set_session_model) and
_restore mint a fresh AIAgent with _session_db_created=False, so the
agent-owns-persistence guard evaluates False and the blind full-history
replace_messages() fired — DELETEing the durable active=0/compacted=1 rows on
any compressed ACP session (same data-loss class the PR fixes, different
trigger).
- hermes_state.replace_messages: add active_only=True to delete/reinsert only
the live (active=1) rows, leaving soft-archived rows untouched (idea adopted
from the competing PR #50306 by @mrparker0980, credited).
- hermes_state.has_archived_messages: cheap existence probe for active=0 rows.
- acp_adapter._persist: when the agent doesn't own persistence but the session
already has archived rows on disk, replace active-only; otherwise the
destructive full replace stays (fresh create/fork has nothing to lose).
- Regression test: model-switch save on a compacted session keeps the archived
turn discoverable via get_messages(include_inactive=True) + search_messages.
ACP's SessionManager._persist() called db.replace_messages() on every
save. That delete-then-reinsert is destructive by design. The agent
backing each ACP session already persists to the same SessionDB itself:
it flushes turns incrementally via append_message and, on context
compression, preserves pre-compaction turns non-destructively through
archive_and_compact() as searchable active=0/compacted=1 rows.
So the per-save replace_messages() was a redundant double-write that
deleted exactly those archived rows (and their FTS entries). Worse,
after a compression-driven id rotation the agent's live head no longer
equals the ACP session id, so the replace overwrote the ended parent
transcript while new turns flowed to the new id — split-brain corruption
of one conversation. Any ACP conversation (VS Code / Zed / JetBrains)
long enough to compress lost history.
Now _persist skips the destructive replace when the agent owns
persistence to this DB (its _session_db is this db and its row exists),
relying on the agent's own incremental + archival flush. It still falls
back to the atomic replace when the agent is not self-persisting — test
agent factories, and fresh create/fork sessions whose copied history the
agent has not flushed yet — so the #13675 rollback guarantee holds.
## What does this PR do?
Fixes silent history loss in ACP editor sessions. ACP _persist no longer
destroys the compression-archived transcript the agent already wrote.
Long enough conversations compress; that compression archives old turns
non-destructively; ACP then hard-deleted them on the next save. After an
id rotation it also clobbered the ended parent and split the
conversation across two ids. This change defers to the agent's own
persistence when it owns the DB and only uses the destructive replace
when nothing else is writing the transcript.
## Related Issue
N/A
## Type of Change
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ] ✅ Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)
## Changes Made
- `acp_adapter/session.py`: in `SessionManager._persist`, guard the
`db.replace_messages()` call. Skip it when the agent owns persistence
to this DB (`agent._session_db is db` and `agent._session_db_created`);
otherwise keep the destructive atomic replace as the fallback.
- `tests/acp/test_session.py`: add a regression test proving archived
(active=0/compacted=1) rows survive a save when the agent self-persists
and stay FTS-searchable; add a test confirming the replace path still
runs for agents that do not own DB persistence.
## How to Test
1. Run `pytest tests/acp/test_session.py -q` — 43 pass.
2. `test_save_session_preserves_agent_archived_history`: archive a turn
via `archive_and_compact`, save, and confirm it survives and is found
by `search_messages` (fails before this fix — replace_messages deleted
it).
3. `test_save_session_still_replaces_when_agent_not_self_persisting`:
confirm history still overwrites cleanly for non-self-persisting
agents.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Salvaged from PR #35130 (the safe subset of jnibarger01's security pass):
- threat_patterns.py: replace unbounded (?:\w+\s+)* filler with bounded
{0,8} + cap scan input at MAX_SCAN_CHARS (64KiB), and bound the .*
runs in the exfil/config-mod patterns. Kills catastrophic backtracking
on adversarial near-misses.
- hermes_state.py: cap FTS5 query length (MAX_FTS5_QUERY_CHARS) and
extract quoted phrases with a linear scan instead of a regex so
pathological quote runs can't induce backtracking.
- acp_adapter/edit_approval.py + agent/tool_dispatch_helpers.py: recognize
'*** Move File: src -> dst' V4A headers so patch-mode edits are
permissioned/traversal-checked (previously only Update/Add/Delete), and
surface a proposal for mode=patch V4A calls (previously replace-only).
Tests: +ReDoS-bound + FTS5-cap + Move-File-target + V4A-approval cases.
Concurrent ACP sessions run on a shared ThreadPoolExecutor (max_workers=4).
Each _run_agent mutated the process-global os.environ["HERMES_INTERACTIVE"]
and restored it in finally, so one session's restore could clobber another's
set mid-run — dropping the second session onto the non-interactive
auto-approve path, executing a dangerous command without the approval
callback firing (GHSA-96vc-wcxf-jjff).
Replace the env-var flag with a thread/task-local contextvar in
tools.approval. The two HERMES_INTERACTIVE read sites in approval.py now go
through _is_interactive_cli() (contextvar-first, env fallback for legacy
single-threaded CLI callers). The ACP executor sets the contextvar instead
of os.environ; the existing contextvars.copy_context() wrapper isolates each
session's write.
Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com>
* feat(moa): expose MoA presets as selectable virtual models
Reconstructed onto current main (PR #46081's base had diverged with no common
ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual
provider: each named preset is a selectable model under provider 'moa', and the
preset's aggregator is the acting model that answers and calls tools.
Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same
batch pattern delegate_task uses) — all references dispatched at once, collected
when every one finishes, then handed to the aggregator. Output order is
preserved, failures and the MoA-recursion guard stay isolated per reference.
- Removed the old mixture_of_agents model tool and moa toolset.
- Added moa as a virtual provider in the provider/model inventory.
- /moa is shortcut behavior over model selection (default preset / named preset
/ one-shot prompt).
- Dashboard + Desktop manage named presets; presets appear in model pickers.
- Parallel reference fan-out in agent/moa_loop.py with regression test.
* fix(moa): thread moa_config through _run_agent to _run_agent_inner
The reconstructed gateway MoA wiring declared moa_config on _run_agent (the
profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper
never forwarded it — _run_agent_inner had no such parameter, so the runtime hit
NameError: name 'moa_config' is not defined on the compression-failure session
sync path. Add moa_config to _run_agent_inner's signature and forward it from
both wrapper call sites (multiplex and non-multiplex). Caught by
tests/gateway/test_compression_failure_session_sync.py on CI shard test(4).
* fix(moa): classify moa as a virtual provider in the catalog
The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so
provider_catalog() fell through to the default auth_type="api_key" with no
env vars — tripping two catalog invariants:
- test_provider_catalog: api_key providers must expose a credential env var
- test_provider_parity: every hermes-model provider must be desktop-configurable
moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that
overlay as an auth_type fallback so the catalog reports moa as virtual (no real
credential, no network endpoint). Exempt virtual providers from the desktop
parity union check the same way 'custom' is exempt — derived from the catalog,
not a hardcoded slug, so future virtual providers are covered too.
Launching Hermes from a directory that ships its own top-level package with a
Hermes-internal name (utils/, proxy/, ui/) crashed the gateway/TUI child with
an ImportError (exit 1, crash loop): from utils import atomic_replace resolved
to the user's package.
tui_gateway/entry.py already stripped the relative cwd forms ('' / '.'), but
the launch dir also reaches sys.path as its own ABSOLUTE path (venv activation
or a project that adds itself to PYTHONPATH), which the strip missed and which
sat ahead of the Hermes root.
Centralize a hardened guard in hermes_bootstrap.harden_import_path(): drop the
relative forms AND force the Hermes source root to the front even when an
absolute cwd entry is present. Wire it into tui_gateway/entry.py and
acp_adapter/entry.py (both spawn into arbitrary cwds); hermes_cli/main.py and
gateway/run.py already insert the root at front. gatewayClient.ts now also
exports HERMES_PYTHON_SRC_ROOT for defense in depth.
CI caught 3 ACP test failures (tests/acp/test_server.py,
tests/acp/test_mcp_e2e.py). Root cause: routing ACP's tool-surface rebuild
through the shared refresh_agent_mcp_tools helper (added in the round-2 pass)
broke a deliberate, pre-existing ACP contract:
- the ACP tests assert `agent.tools is <get_tool_definitions return>` (object
identity) and an exact get_tool_definitions(enabled_toolsets=[...],
disabled_toolsets=..., quiet_mode=True) call signature; the shared helper
list()-copies and re-derives differently, breaking identity; and
- the tests use a MagicMock agent whose _tool_snapshot_generation is a mock, so
the new `int < published_gen` generation guard raised TypeError and the whole
ACP refresh silently failed.
ACP already preserves memory-provider tools (its own inject call) and excludes
context_engine, so there was no bug to fix there — only over-reach. Reverted ACP
to its original rebuild. (Same lesson as the gateway path: leave call sites that
carry their own tested contract alone; a reviewer's "inert today, fragile" note
meant leave-it, not change-it.)
Also hardened the generation guard defensively: tolerate a non-int
_tool_snapshot_generation (mock / partially-built agent) instead of throwing
TypeError and silently failing the refresh.
Second review pass (Codex + Hermes subagent). Codex reproduced a real race with
a two-thread harness; both converged on the remaining issues.
- Generation-aware publish (fixes a lost-update race): two refresh callers (the
late-refresh daemon and the between-turns prologue around turn 1) could each
compute a snapshot outside the lock; a SLOWER caller holding an OLDER registry
generation could acquire the publish lock after a newer caller and clobber it,
deleting just-landed tools. refresh_agent_mcp_tools now captures
registry._generation before computing and refuses to publish a stale set;
agent._tool_snapshot_generation tracks the published generation.
- Context-engine routing names (_context_engine_tool_names) are now staged on a
local and published atomically with the snapshot, and only claimed when this
rebuild actually appended the schema — matching agent_init's dedup so a
registry/plugin tool of the same name keeps its own dispatch. (Previously
mutated live, before the publish lock, and on no-change refreshes.)
- CLI /reload-mcp: self.enabled_toolsets is resolved once at startup, so a
server newly ENABLED in config mid-session wasn't picked up (TUI already
re-resolved). Merge now-connected MCP server names into the override (unless
the user pinned all/*), mirroring startup, and keep self.enabled_toolsets in
sync. Closes the CLI/TUI parity hole.
- ACP (acp_adapter/server.py) routed through the shared helper — it was a 5th
sibling rebuild that re-injected memory tools but NOT context-engine tools and
bypassed the atomic/name-diff path (inert today, fragile).
- mcp_startup._resolve_discovery_timeout pulls its default from DEFAULT_CONFIG
(single source of truth) instead of a stale hardcoded 5.0 literal.
- Tests: stale-generation-no-clobber, _skip_mcp_refresh honored, timeout
fallback uses DEFAULT_CONFIG.
Closes#33617. Adds additive _meta.hermes.sessionProvenance to ACP session
surfaces so clients can detect compression-driven internal session rotation
without parsing status text, guessing from token drops, or reading state.db.
Derived on demand from the existing compression chain (parent_session_id /
end_reason) — no new persisted state, no schema change, no ACP protocol change.
ACP session_id stays the stable client handle.
- acp_adapter/provenance.py: derive provenance from SessionDB
- server.py: attach _meta to new/load/resume responses; emit a
session_info_update when the internal head rotates during a prompt