- atlassian: /v1/sse was deprecated by Atlassian after June 30 2026 —
installs OAuth'd fine then failed every handshake with 404 (#91538).
Now points at /v1/mcp/authv2, the endpoint Atlassian's docs recommend
for custom clients (live-probed: OAuth 2.1 + DCR intact).
- grafana (on top of @cedricziel's #93183 entry): defensive
default_excluded for ask_assistant (opaque Assistant delegation
meta-tool, bills usage) and agento11y_* (Grafana's own agent-obs
product suite) — both from Grafana's published tool tables, both
no-ops until the Cloud surface serves them; billing note added to
post_install.
Vendor-hosted remote MCP at https://mcp.grafana.com/mcp over Streamable
HTTP with native OAuth 2.1 + Dynamic Client Registration -- the same
transport/auth shape as the existing datadog and sentry catalog entries,
handled by Hermes's MCP client + mcp_oauth_manager.
Exposes Prometheus/Loki/Tempo/Pyroscope queries, dashboards, datasources,
alerting, incidents, and Grafana Assistant investigations, user-scoped to
the operator's Grafana RBAC. This is the hosted Grafana Cloud MCP, not the
self-run OSS mcp-grafana binary; post_install points self-hosted users to
the OSS server.
Source: https://grafana.com/docs/grafana-cloud/ai-tools/mcp-servers/cloud-mcp/
On Windows, the pre-update concurrent-instance gate aborted with exit 2
whenever ANY other process held the venv hermes.exe shim — including the
gateway itself, which _pause_windows_gateways_for_update() stops moments
later and the post-update restart phase brings back. Users with a running
gateway were forced into a manual taskkill dance before every update.
The gate now filters gateway runtimes out of the abort list and proceeds
when nothing else is concurrent. Classification delegates to
_is_pausable_gateway -> gateway.status.looks_like_gateway_command_line
(the canonical shlex-tokenized, profile-selector-aware matcher shared by
the Desktop preflight exemption and the venv-holder guard fallback), so
the gate's exemption and the pause machinery cannot drift apart. Anything
not positively identified as a gateway — REPLs, dashboard, Desktop
backend children, gateway MANAGEMENT commands like 'gateway status',
unreadable cmdlines — still aborts exactly as before, and the abort
message now lists only the PIDs that are actually the user's problem.
Surgical reapply of PR #37039 by @damadorPL onto current main (the gate
moved from hermes_cli/main.py to hermes_cli/update_cmd.py in the main.py
decomposition); his substring classifier was replaced with the canonical
matcher, which also fixes the 'hermes gateway status' misclassification
flagged in review.
Co-authored-by: Hermes <hermes@nousresearch.com>
Electron safeStorage parks a per-app key ('Hermes Key') in the macOS login
keychain; on machines with a locked/missing/corrupted default keychain that
turned every Hermes Desktop launch into a blocking 'Keychain Not Found' /
password dialog. Keychain-backed encryption is now an explicit opt-in:
- electron/secret-storage-policy.ts: standalone policy seam (default OFF,
strict === true coercion, one-shot migration flag) + unit tests
- default path never calls any safeStorage API (including
isEncryptionAvailable, which itself touches the keychain)
- one-shot legacy migration decrypts existing safeStorage blobs to plain
0600 files at first launch; undecryptable blobs are kept but read as
absent afterward (classify 'drop') so a dead keychain prompts at most once
- Settings -> Gateway toggle (all 5 locales) re-encodes every stored secret
store in place when flipped (v1 connection.json, v2 connections.json,
native-oauth-tokens.json)
- e2e: at-rest spec now covers both postures (opted-in unchanged contract,
default saves without secure storage, owner-only bits, restart round-trip)
- docs: multi-connection-desktop + desktop-native-signin updated
Port of @jeff-mettel's fix onto the post-#91378/#92902 fleet-restart
shape. The current-profile restart was gated on `launchctl list <label>`
exiting 0 - a booted-out job (plist present, definition deregistered:
crashed helper, manual bootout, failed prior update) fails that check,
so the branch silently skipped: no restart, no message, KeepAlive unable
to revive a definition launchd no longer knows, update printing
'Update complete!' with the gateway down. `launchctl list` is also
session-scoped and unreliable as a loaded/unloaded classifier.
- _restart_launchd_gateway_after_update() (his extraction, adapted):
plist-exists is the ONLY gate; launchd_restart() owns the
bootout/bootstrap/kickstart ladder for every plist-present state;
every failure path is loud and names the manual recovery command.
The gate-error 'except: pass' (the second silent variant) now counts
the label failed and tells the operator.
- Success still requires the #92902 supervision verify (fresh
supervised PID), composing his fix with the returned-is-not-supervised
guard.
- His regression suite adapted to the (restarted, failed) contract; the
old 'unregistered -> left alone' pinning test FLIPPED - it pinned the
bug.
A/B: his suite + the flipped test red on merge-base product code
(silent skip live), green at head. No macOS CI lane exists; field
evidence is #74973's reproductions plus the launchctl print output
shapes pinned in the suite.
* feat(cron): durable failure incidents with signature dedup and ack
Introduce a durable cron incident store (cron_incidents in the shared
cron/executions.db) that groups "same job + same error signature" across
runs, so a known recurring failure stops re-pinging the operator every run
once it has been acknowledged.
- cron/incidents.py: lazily-created incident table (detected -> alerted ->
reviewed -> closed lifecycle; closed is per-signature terminal), sha256
signature dedup over job_id + normalized error, redacted/truncated error
storage, failure-type classification, and ack/list/get/count helpers.
- cron/scheduler.py: record an incident on the failure delivery path and
suppress the per-run failure ping when the exact signature is acked (both
the normal failure path and the processing-raised retry path). Best-effort:
an incident-store error never breaks the cron run or delivery. Streak nudge,
alert-once markers, and delivery-error behavior are untouched.
- hermes_cli: add `hermes cron incidents [--state ...]` and
`hermes cron incidents ack <id>`.
- tests/cron/test_cron_incidents.py: dedup, lifecycle, redaction,
classification, lazy-schema, scheduler gating, and CLI coverage.
Non-goals deferred to later slices: Discord buttons/review view, HMAC action
tokens, owner-agent review launch, approval-gated fixes, incident playbooks.
* refactor(cron): tighten incident lifecycle, wire alerted state and suppressed_acked outcome
Follow-ups on top of the salvaged #94692:
- Drop the dead 'reviewed' state and the SQLite CHECK (state validity
lives in INCIDENT_STATES so future slices can add states without a
table rebuild); lifecycle is detected -> alerted -> closed.
- Actually mark incidents 'alerted' after a failure ping reaches
delivery, on both the normal and exception delivery paths.
- Record ack-suppressed runs with a distinct 'suppressed_acked'
delivery outcome (registered in cron_health monitoring) instead of
the ambiguous generic 'suppressed'.
- Drift-skip alerts explicitly bypass the ack gate (they carry the
remediation command and alert once via drift_alerted already).
- Docs: failure-incidents section in the cron guide.
- Tests for the alerted transition + never-resurrect-closed.
---------
Co-authored-by: Laura López Real <113060513+laulopezreal@users.noreply.github.com>
* refactor(prompt): remove the Nous Subscription block from the system prompt (~1.2K tokens/call)
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
Merge follow-up on top of the salvaged commits: fold the npm>=10 reduced
hidden-lockfile comparison (intersection of non-null fields) and the
annotation-field exclusions into a single entries_differ() helper, keeping
the workspace-closure scoping intact.
_tui_need_npm_install compared every field of the root package-lock.json
against node_modules/.package-lock.json. npm>=10/11 writes a reduced hidden
lockfile that omits declarative fields (version/dependencies/dev) and adds
extraneous, so nearly every package looked 'changed'; workspace link entries
("link": true, paths outside node_modules/) are never materialized by the
partial --workspace install. Both made the check return True forever, so
hermes --tui re-ran npm install (and dirtied package-lock.json) on every
launch (#84617).
Compare only the keys both sides record with non-null values (resolved,
integrity, ...), ignore workspace link entries and non-node_modules paths in
the missing-entry check, and treat extraneous as an npm runtime annotation.
Real skew (lockfile bumped while node_modules is behind) is still detected.
_tui_need_npm_install() compared ALL fields between
package-lock.json and .package-lock.json, treating npm's
intentionally-stripped metadata (version, license, engines,
dependencies, etc.) as real skew. This caused 'Installing
TUI dependencies…' + rebuild on every launch.
Two changes:
1. Compare only the *intersection* of fields between the
two lockfiles — a field present in the root lock but
absent in npm's hidden actualized lock is a normal npm
artefact, not a real dependency change.
2. Add , , ,
to _NPM_LOCK_RUNTIME_KEYS — these boolean annotations
are written non-deterministically between the two locks
and never indicate a real dependency skew.
Real version/dependency changes still change /
, which are present in both locks and caught by
the intersection comparison.
Fixes: 347 false-positive lockfile mismatches → 0.
On Termux the launch install also selects ui-tui's child packages/*
workspaces (include_child_workspaces=True), so npm installs each child's
devDependencies. The freshness closure only followed devDependencies for
the ui-tui workspace itself, so a devDependency unique to a selected child
was dropped from the closure and a genuine missing package slipped past
_tui_need_npm_install.
Derive the closure from every workspace the install path selects, following
devDependencies for each. _npm_lock_workspace_closure now accepts the set of
selected workspace keys (dev-included roots); _tui_selected_workspace_keys
mirrors _make_tui_argv (ui-tui, plus child packages/* on Termux). Adds a
child-workspace-devDependency regression test (installs on Termux, ignored
off Termux) plus a closure-level dev-scope test.
_tui_need_npm_install compared the full multi-workspace root
package-lock.json against the hidden .package-lock.json, but the launch
install is scoped with npm install --workspace ui-tui and only writes the
ui-tui dependency closure. Every dep belonging solely to another workspace
(apps/desktop, web, ...) was therefore reported as missing, so the check
returned True and printed "Installing TUI dependencies..." on every launch.
Restrict the comparison to the ui-tui workspace's dependency closure,
computed from the root lock's packages map (following npm's node-resolution
walk and workspace symlinks). Standalone / own-lockfile layouts and any
case where the workspace can't be located fall back to the full comparison,
so drift on a genuine ui-tui dependency is still detected.
Fixes#66978
Sidebar and legacy session-list helpers tagged the registry connection but
not the active profile, so Electron routed those reads to the wrong backend
after a profile or remote switch.
Keep hermesApi connection-only: stamp profileScoped on the list helpers
instead of every REST call.
Co-authored-by: noah <loahnisk@gmail.com>
Managed SSH maps a Desktop profile label onto a different remote name.
The sidebar filter lives in recents_profile, so rewriting only ?profile=
left those reads on the remote default and the Sessions list came back empty.
Co-authored-by: noah <loahnisk@gmail.com>
The function _persist_live_session_system_prompt runs on the RPC
dispatcher thread. A model switch calls it. On that thread the
_SESSION_CWD contextvar is not set. Because of this,
resolve_agent_cwd() falls back to the process TERMINAL_CWD value.
The desktop pins TERMINAL_CWD to the home directory. The rebuilt
prompt then records the home directory as the working directory.
The wrong prompt persists to the session database. Later turns
restore the stored bytes without change, because the turn prologue
rebuilds the prompt only when the cache is empty. The wrong line
never heals. The terminal tool is not affected, because it reads
the per-session cwd record.
The desktop composer sends a model switch before the first turn
when its model differs from the config default. Because of this,
a new project session can show the wrong working directory for
its full life.
Fix: bind the session context around the rebuild, in the same way
the function already binds the profile home for issue #50233. The
test drives the real function from a bare thread and asserts the
persisted prompt carries the session cwd.
Adds a 'web_extract shows a stale page' section to the bundled
hermes-agent skill's troubleshooting reference: the 20-minute TTL
symptom, the automatic never-cache carveouts (localhost/private/LAN,
website_blocklist, rescue/failed responses), the web.cache_exempt_hosts
recipe for staging/tunnel sites, and the TTL/disable fallbacks.
Sites under active development but tested over the public internet
(Vercel previews, ngrok tunnels, staging domains) are public DNS, so
the local-dev never-cache rule can't catch them. web.cache_exempt_hosts
lists hosts whose pages are always fetched live: exact, "*.wildcard",
or domain-suffix matching (label-boundary aware — mysite.dev covers
preview.mysite.dev but never evilmysite.dev). Checked on both store
and lookup, so adding an exemption takes effect immediately even for
entries cached before the config change.
Dev servers, hot-reload builds, and chat-GUI artifact previews live on
localhost/private addresses and change on every save — a 20-minute
cached copy would show a stale build exactly when freshness is the
point of fetching. The extract cache now declines loopback, private,
link-local, *.local, *.localhost, and single-label LAN hostnames on
both put and get. Hostname heuristics only (no DNS) — this is a
freshness carveout, not a security boundary; SSRF enforcement is
unchanged in tools/url_safety.py.
Public URLs keep the full TTL.
Review fixes for #94618 (all three blockers reproduced by the reviewer
through the real web_extract_tool):
1. Cache lookup moved AFTER provider resolution and strict-selection
validation, and gated per-URL on the website blocklist policy — a
blocklist-blocked or misconfigured-backend call now behaves exactly
as it would without a cache instead of serving cached content.
2. Rescue-served extract batches are never cached (mirrors the search
memo's exclusion), keeping one-shot rescue one-shot.
3. Cache entries now get dedicated per-(url, format, provider) files
instead of sharing the URL-keyed truncate-store file — html and
markdown (or two backends') copies of one URL no longer overwrite
each other, and switching extract backends within the TTL never
serves the old backend's rendering.
Also from review: per-process index tmp filename (cross-process writers
can no longer truncate each other mid-write) and held flight locks are
never evicted from the bounded lock table (eviction could have allowed
a duplicate paid request).
New regression tests for formats/provider keying; E2E harness extended
with policy-block, strict-selection, rescue-two-call, and dual-format
scenarios — 6/6 pass; original 13/13 still pass.
Repeat searches (same normalized query + provider) within a 20-minute
TTL are served from an in-process memo, and concurrent identical
queries are single-flighted so a parallel subagent fan-out pays for
one vendor request instead of N. Requested limits bucket up to
10/20/50/100 so near-identical requests share an entry; callers get
their requested count sliced from the bucket.
Repeat extracts of the same URL are served from the existing
cache/web full-text store (previously written for read_file paging
but never read back), via a small JSON sidecar index. Disk-backed, so
CLI, gateway, cron, and subagents share it. Cached extracts re-run
the normal truncate pipeline, so per-call char_limit still works.
Both caches sit after every safety gate (secret-URL, SSRF, policy,
provider resolution) and directly around the paid vendor call — hits
skip only the network request. Only successful responses cache;
rescue-served responses are never cached (one-shot rescue must stay
one-shot). Config: web.cache_enabled (default on),
web.cache_ttl_minutes (default 20, clamped 1-1440).
Idea credit: query coalescing + num-bucketing pattern observed in
Apodex FrontierAgent (Apache-2.0).
Closes the two config-UI halves of the exclude-mode review (GottZ findings
5-7 on #94513):
- hermes mcp configure: on an exclude-mode server, unchecking a tool now
APPENDS a literal exclude and re-checking drops it — glob patterns are
preserved so future vendor tools keep getting filtered. Previously one
uncheck converted the whole config to a frozen include list (globs
silently deleted, new vendor tools invisible). Re-checked tools still
shadowed by a kept glob get an explicit warning instead of a silent
no-op.
- hermes tools MCP checklist: same exclude-mode write-back, plus display
now matches excludes via matches_name_filter (fnmatch) — glob excludes
previously rendered as if nothing were excluded.
- klaviyo manifest: post_install no longer tells users to append a param
the URL already pins; now documents how to get the FULL surface.
Live-verified through the real cmd_mcp_configure path: exclude-mode server
with ['*_secret_*', 'docs'], uncheck beta + re-check docs ->
exclude becomes ['*_secret_*', 'beta'], no include written.
171 tests green across test_mcp_catalog/test_mcp_config/test_mcp_tool.
The probe-fail path ignored prior state entirely: with no manifest
default_enabled it wrote include=None, which pops the whole tools
block. For exclude-mode manifests default_enabled is necessarily unset,
and for the 30+ OAuth entries the entry rewrite precedes first auth —
so the common reinstall-while-unreachable case removed the curated
excludes and enabled every tool on next connect. The fallback order is
now: prior include > prior exclude > manifest default > no filter.
The "Probing '<name>' for available tools..." line printed before the
exclude-mode short-circuit, which deliberately never probes (the test
suite asserts _probe_tools must not run there). Move the announcement
next to the actual probe call so install output matches what happened.
_normalize_name_filter([]) returns an empty set, which is falsy, so
_should_register fell through to "no filter" and registered every tool
— the exact opposite of what _apply_tool_selection wrote when the user
unchecked everything in the install checklist ("contributes nothing
until reconfigured"). Whitelist mode is now keyed on the include key
holding a valid filter shape (str/list/tuple/set) rather than on set
truthiness, at both the live-discovery and cached-manifest sites.
Invalid include values keep the old warn-and-ignore behaviour.
The test wrote the evil manifest and imported install_entry but never
called it and asserted nothing, so the security gate in
_save_mcp_server/validate_mcp_server_entry was left uncovered. Now it
calls install_entry, expects CatalogError, and verifies the entry was
not persisted. Also drops the hardcoded ~/.hermes/ path from the
fixture args (AGENTS.md tests rule); the egress + exfil-hint shape
still trips both patterns.
Review blockers (independent reviewer on #94513):
1. Reinstalling an exclude-mode catalog entry wiped the user's edited
tools.exclude, replacing it with manifest defaults. install_entry now
reads the prior exclude (like it already did for include) and re-writes
it verbatim on reinstall. Regression test added + sabotage-verified
(fails on old behavior); include-priority test added too.
2. aws-knowledge: exclude aws___retrieve_skill — vendor SKILL.md loader is
a vendor skill layer (live tools/list confirmed the tool exists).
3. betterstack: exclude list rewritten to cover the snake_case wire names
(vendor's own header examples show remove_dashboard) via globs alongside
the doc display-labels; caveat documented in the manifest — server is
OAuth-gated so pre-auth enumeration is impossible.
4. railway: exclude railway-agent (opaque server-side agent delegation,
acts outside Hermes's per-tool approval loop).
5. twelve-data: exclude oauth plumbing pseudo-tools + quota probe.
6. betterstack post_install no longer claims a fully-checked checklist —
exclude-mode bypasses the checklist; text now describes the applied
exclude list.
Live E2E: fresh temp HERMES_HOME — install applies manifest excludes,
user edit survives reinstall. 33/33 catalog tests green.
The cloudflare entry's 3,320-endpoint surface is ~43% product families a
personal/dev account never touches (Zero Trust org-fleet suite, Magic
Transit/WAN, Cloudforce One, Radar analytics, API Shield, legacy
migration surfaces). Ship a 34-pattern curated exclude list in the
manifest: 3,320 -> 1,905 tools kept, and everything Cloudflare adds
later stays enabled by default.
Mechanism, two small extensions:
- tools/mcp_tool.py: tools.include/exclude entries containing glob
metacharacters now match via fnmatch (plain names stay exact-match),
so a product family is one pattern instead of hundreds of stale
literals.
- hermes_cli/mcp_catalog.py: manifests may declare
tools.default_excluded (mutually exclusive with default_enabled);
install writes it to tools.exclude and skips the probe/checklist —
a 3,320-row curses checklist is not a UX. Prior user include
selections still win on reinstall.
Verified by replaying the real filter functions over the live-probed
3,320-tool list: 1,415 excluded, zero overmatch against a per-product
target audit; DNS/Workers/R2/D1/tunnels/Access/AI kept.
The server's default Code Mode surface (search/execute meta-tools) is
itself a tool-discovery layer; stacking it under Hermes tool_search
would mean two search hops and an opaque 2-tool surface. With
?codemode=false each of the ~3,300 API endpoints registers as its own
tool with a full JSON Schema, and Hermes's own progressive disclosure
defers and searches the complete catalog — one layer, total
information. Verified live: tools/list returns 3,320 tools, all with
input schemas. post_install documents the trade-off and how to opt
back into Code Mode.
Adds optional-mcps/cloudflare — Cloudflare's managed remote MCP server
(mcp.cloudflare.com/mcp) fronting the entire Cloudflare API (2,500+
endpoints across DNS, Workers, R2, KV, D1, Zero Trust, WAF, Pages)
through two Code Mode tools, search() and execute(), at a fixed ~1k-token
schema footprint. HTTP transport + native MCP OAuth 2.1 with DCR — no
install block, nothing to pin. post_install documents the scoped OAuth
grant, the bearer-token path for headless/CI, and Cloudflare's
product-specific servers for narrower surfaces.
Docs: mention Cloudflare in the hosted-OAuth MCP examples.
Review finding on #94633: _sanitize_label_value is lossy ('team/workspace'
and 'team_workspace' both sanitize to 'team_workspace'; >63-char keys
truncate identically), and container reuse is label-keyed — so two teams
with DIFFERENT shared keys could silently attach to one running container
(filesystem, processes, env) while their host sandboxes stayed separate.
Shared-key labels now carry a sha256 digest suffix of the raw key
(deterministic across processes; plain profile labels unchanged for
backward compat). Docs also state the first-creator-wins rule for image/
mounts on a shared container. Adds adversarial collision tests.
Follow-up on @fangliquanflq's opt-in (#84775): after the profile-scoping fix
(#94560) the container cache key is resolved in _resolve_container_task_id,
so the shared key must unify profiles there too — 'shared:<key>' for every
session of every opted-in profile AND for CLI/no-session runs. Delivery adds
the shared sandbox layout as the first translation candidate. Empty key
keeps strict per-profile isolation; SSH ignores the key entirely.
Independent review caught a compaction authority the gate missed:
post-turn micro-compaction (turn_finalizer -> _micro_compact) absorbs the
oldest exchanges into a rolling summary with no pre-compress checkpoint
hook in its path, and both compression.checkpoint_required and
compression.micro_compact could be enabled together — assistant evidence
could vanish into a summary the checkpoint filter later excludes, without
ever reaching the durable provider.
- agent_init: checkpoint_required forces micro-compaction off (warned),
mirroring the native-compaction suppression
- turn_finalizer: defense-in-depth guard at the call site (attribute is
plain mutable state a future path could flip on a live agent)
- behavioral regression test with a sabotage control (gate off proves the
harness reaches the call site; gate armed proves zero calls)
- docs + config example mention the suppression; stale v1 test header fixed
Per review: existing providers should not be retroactively re-versioned or
handed a changed payload. Version 1 is now the implicit historical
on_pre_compress() contract (best-effort, raw message list) that every
pre-existing provider is already on; the fail-closed checkpoint contract
becomes version 2. MemoryManager routes the raw transcript to v1 providers
unchanged and hands the host-normalized evidence list only to v2+ checkpoint
providers, so the plugin surface contract for shipped providers is
byte-identical with the gate off.
The fail-closed gate lived only in compress_context(), but two native
lossy owners compact without ever crossing it (review on #93996):
- codex app-server: in "native"/"off" auto-compaction mode (native is the
default) Hermes preflight is skipped and the codex agent compacts its
own thread inside run_turn() — the compress_context() rejection was
unreachable. init_agent now refuses checkpoint_required together with
api_mode=codex_app_server (BLOCKED_MISSING_PREREQUISITE, extracted as a
testable guard), and run_codex_app_server_turn() fails closed as
defense in depth before a turn can reach the codex-owned boundary.
- Responses server-side native compaction:
native_compaction_context_management() now returns None while the gate
is armed, so context_management never goes on the wire and the
checkpoint-aware Hermes compressor stays authoritative. The suppression
is logged once per process, not silently applied.
Regressions: checkpoint_required + app-server raises before run_turn()
(the session is never created); checkpoint_required keeps
context_management off the wire while the plain configuration still
produces it; the init guard refuses exactly the incompatible pair. Docs
and cli-config.yaml.example describe both bindings.
Refs #93986
Addresses the review on #93996:
- gateway: hygiene and manual /compress load the memory provider only when
compression.checkpoint_required is enabled (skip_memory=not required).
The historical fast path — no provider init, no best-effort hook — is
back for everyone who did not opt in, so default behavior is truly
unchanged.
- conversation_compression: assistant messages carrying both prose and
tool_calls keep their prose in the checkpoint evidence (the tool_calls
payload is stripped, the original message is not mutated); pure
tool-call wrappers without prose are still dropped.
- tests: legacy-database regression proving the _compressed_summary column
is added by the declarative _reconcile_columns() path on a plain reopen
(no version-gated migration needed — append_message works right after),
plus coverage for the prose-preserving filter.
- docs: providers must implement idempotent, content-keyed checkpoint
writes — a fail-closed block means the next attempt re-runs
on_pre_compress over largely the same transcript.
Refs #93986
Adds a 'Pre-Compress Checkpoints (fail-closed)' section to the memory
provider plugin guide: the versioned opt-in attribute, the operator-side
compression.checkpoint_required gate, fail-closed semantics, and the
normalized evidence contract including the persistent summary marker.
Refs #93986
Context compression is intentionally lossy. Deployments that archive
transcript evidence to an external durable store before compaction had no
way to guarantee the archive actually happened: MemoryManager.on_pre_compress
swallows provider failures by design, so a failed archive silently degraded
into data loss.
This adds an opt-in, provider-agnostic checkpoint contract:
- memory_provider: PRE_COMPRESS_CHECKPOINT_API_VERSION = 1; providers opt in
by advertising pre_compress_checkpoint_api_version. Version 0 keeps the
historical best-effort hook semantics.
- memory_manager: supports_pre_compress_checkpoint() capability probe;
on_pre_compress(require_checkpoint=True) propagates checkpoint-provider
failures and raises when no capable provider completed the checkpoint.
- conversation_compression: new compression.checkpoint_required config key
(default false, documented in cli-config.yaml.example). When enabled,
compaction fails closed with BLOCKED_MISSING_PREREQUISITE (the
uncompressed transcript is preserved) unless a checkpoint-capable provider
confirms the durable checkpoint. Providers receive normalized direct
user/assistant evidence: tool rows, system messages, tool-call wrappers,
and prior compaction summaries are filtered host-side into one stable
contract. codex_app_server compaction is rejected under the gate because
it exposes no truthful pre-compaction transcript boundary.
- hermes_state: persistent _compressed_summary column (declarative schema
migration via _reconcile_columns) so summary provenance survives process
restarts; only the resume model history carries the marker, keeping
get_messages_as_conversation on its existing contract.
- gateway: the lossy hygiene/auto-compact paths load the memory provider
(skip_memory=False) so a required checkpoint also guards those rewrites.
The gate arms only on an explicit boolean True (bare-MagicMock agents in
existing tests have truthy auto-attributes). Default behavior is unchanged:
checkpoint_required=false preserves best-effort semantics for all existing
providers. Contract tests, including a restart round-trip of the summary
marker, in tests/agent/test_pre_compress_checkpoint_contract.py.
Refs #93986