Phase 8 of the Hermes Agent token leak mitigation plan
(ralplan-hermes-token-leaks.md §3.9). Adds a boolean kwarg
`skip_background_review` (default False) to AIAgent.__init__ that
suppresses the end-of-turn _spawn_background_review fork.
Each background review fork instantiates a new AIAgent with its own
~15K input tokens + up to 8 LLM iterations, accumulating ~30K tokens
per event in the worst case. On cron sessions there is no
human-in-the-loop benefit from the review (no skill-creation pressure,
nobody curating MEMORY.md), so the cost is pure waste.
The end-of-turn guard now reads:
if (final_response and not interrupted
and not getattr(self, "skip_background_review", False)
and (_should_review_memory or _should_review_skills)):
skip_memory=True already disables the memory-review trigger; this
flag is the explicit single-switch off for both review paths.
Defaults to False, so behavior is unchanged for gateway/CLI callers
that omit the kwarg.
Tests: 5 new unit tests in tests/agent/test_skip_background_review.py
covering the default value, flag persistence, the gate short-circuit,
the gate fall-through, and a source-text assertion that the cron
scheduler sets the flag to True (separate commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to the review on the session.resume ownership fix. Closing the
pre-transfer early returns left two gaps, both real.
1. The transfer had no owner on the other side. Once ownership moved to the
agent, teardown ran AIAgent.close() (via _teardown_session on session.close
and the orphaned-session reaper), which called session_db.end_session() —
that finalizes the session ROW, not the connection. A successfully resumed
profile session kept its dedicated handle, its db/-wal/-shm fds and its
background token-writer thread for the life of the gateway.
AIAgent now carries an explicit _owns_session_db, defaulting False so the
SHARED launch handle — which outlives every agent and backs every other live
session — is still never closed there. Only the dedicated-open sites set it,
at the point ownership actually changes hands.
2. session.resume was not the only profile-scoped open with no close on its
failure paths. Covered here with the same flag, via a _transfer_db_to_agent
helper that refuses the transfer unless the agent really holds that handle:
- the deferred builder (_start_agent_build), including the session-reaped-
mid-build case, where the built agent is discarded and never torn down, so
transferring to it would leak exactly as before;
- session.branch's branch_db;
- the compute host's per-profile open;
- AIAgent's own lazy open in _get_session_db_for_recall, which no other
object ever references and so was unconditionally abandoned.
Where a handle has already reached a registered session, the drop is
unconditional and the transfer is best-effort on top: a refused transfer leaves
the old leak, which is survivable, whereas closing under a live session is the
permanent "Cannot operate on a closed database" break the original patch exists
to avoid.
Tests: tests/tui_gateway/test_session_db_ownership_teardown.py (new, 14).
11 of the 14 fail without this change; the 3 that pass are the "must NOT close"
guards, which hold in both directions by design.
* feat(agent): read_preview — the desktop-gated tool that reads the in-app browser
The agent could open the preview pane (open_preview) and read the embedded
terminal (read_terminal), but the browser it had just opened was a black box —
'what does this page say?' had no answer. read_preview mirrors read_terminal
end to end: HERMES_DESKTOP-gated via check_fn (zero schema footprint outside
the GUI), dispatched through the same agent callback pattern, windowed with
start/count so a long page pages instead of flooding context.
* feat(gateway): preview.read blocking bridge
Same lifecycle as terminal.read: the tool blocks on preview.read.request, the
renderer answers preview.read.respond (allow_expired — a slow page extraction
losing the 45s race must not surface a raw 4009), and a timeout emits
preview.read.expire so late answers resolve quietly.
* feat(desktop): the renderer serializes the active preview tab for the agent
preview-reader.ts is the preview analog of the terminal's buffer registry: the
URL pane registers a page reader (webview executeJavaScript → title + visible
innerText) keyed by tab id; readActivePreview resolves the ACTIVE tab, windows
the text (24k cap per read), and answers file/artifact tabs with identity plus
a note pointing at the tool that reads that content directly. The gateway
event handler answers preview.read.request beside terminal.read.request.
Empty model.base_url plus a runtime custom-provider URL was treated as a
route mismatch, so gateway session-reset banners dropped model.context_length
and fell back to the Qwen family default (131K) while /status still showed
the configured 262K pin.
A session's YOLO bypass lived only in the in-memory
tools.approval._session_yolo set (or the process-frozen --yolo env
var), so resuming a session in a fresh process silently reverted the
user's /yolo ON — dangerous commands started prompting again.
Persist a yolo_mode flag in the session row's model_config JSON and
restore it on every CLI resume path:
- SessionDB.set_session_yolo() merges the flag into model_config
(same lineage-preserving merge as update_session_runtime_lock);
SessionDB.session_yolo_enabled() reads it back, false on any parse
failure.
- /yolo toggle persists ON and OFF through the new helper; the
compression/branch session-id rotation carries the flag onto the
continuation row.
- --yolo launches record the flag at session creation (agent_init),
and a /yolo toggled before the lazily-created row exists is carried
into the creation-time model_config (_ensure_db_session).
- HermesCLI._restore_session_yolo() re-enables the bypass on startup
--resume/-c, the deferred init path, and mid-chat /resume, with a
visible '⚡ YOLO mode restored from session' notice. No-op under a
frozen process-wide --yolo and never enables on absent/garbage flags.
Three mechanisms to detect and notify when gateway sessions stall silently:
1. Mid-turn activity heartbeats stamped to SessionDB so hermes sessions list
and hermes status show progress during long turns without new message rows.
2. Stall watchdog: when a busy session has pending inbound and the shared
activity clock is idle past agent.session_stall_timeout (default 300),
log a WARNING and notify the user once to try /new. Notify-only; does
not kill the turn.
3. Compaction timeout: fenceless compress_context callers get a progress-aware
host budget (compression.context_timeout_seconds default 120 idle,
compression.context_total_ceiling_seconds default 600 ceiling). On timeout,
cancel via commit fence, skip compaction without dropping messages, and
continue the turn.
Closes#72016 (slices 1-3; slice 4 cumulative SSE stream-retry deadline
remains a follow-up).
Cherry-picked from PR #72424 by @fangliquanflq.
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.
Follow-ups from review of #76113:
- Extract cache_ttl_means_disabled() as the single disable-synonym
predicate; agent_init and prompt_caching_disabled_from_config both use
it so the two detection sites can no longer drift (drift would recreate
the #76085 bug class).
- Mirror _run_reference's not-None injection guard in
aggregate_moa_context (stamping None was a harmless no-op copy).
- Replace a vacuous trailing test assertion with the intended
input-non-mutation check; drop a stray blank line.
- Add a predicate-parity regression test (unknown TTL values keep
caching enabled, matching historical agent_init semantics).
Setting prompt_caching.cache_ttl to a falsy value (false, null, off,
disabled, no, none) now fully disables prompt caching instead of
being silently ignored.
The disable propagates through anthropic_prompt_cache_policy() (early
return when _cache_disabled flag is set) and restore_primary_runtime()
(override after snapshot restore), so it survives /model switches and
fallback re-derivation — the gap that caused #56105 to be reverted in
#56126.
Salvage of #33555 by @BB-light, with model-switch/fallback survival
gap fixed on top.
Co-authored-by: BB-light <BB-light@users.noreply.github.com>
agent_init.py's init-time fallback and chat_completion_helpers.py's
try_activate_fallback() still read key_env via raw os.getenv(), missing the
per-profile secret scope installed by the multiplexed gateway (same bug
fixed for fallback_config.py/auxiliary_client.py in this PR). Both now
delegate to hermes_cli.fallback_config.resolve_entry_api_key(), and the
Ollama Cloud OLLAMA_API_KEY read now goes through
agent.secret_scope.get_secret() too.
agent_init.py's fallback loop had no try/except around key resolution
(unlike the other three call sites), so a fail-closed UnscopedSecretError
under multiplexing would have crashed init instead of skipping to the next
fallback entry — added the same skip-and-continue handling.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AIAgent.__init__ calls set_current_session_id(self.session_id), which
mutated both the task-local ContextVar and the process-global os.environ.
Because _build_child_agent wraps construction in delegated_child_context(),
the ContextVar write is harmless (task-local), but the os.environ write
clobbered the parent's HERMES_SESSION_ID for the rest of the process —
leaking the child id into parent tools and subprocesses spawned after
the child was built.
Root cause of HermesPRDelegationSessionContext: parent
20260729_212118_5d797e dispatched child 20260730_160515_736ea1; later
parent terminal inherited HERMES_SESSION_ID=the child.
Fix: set_current_session_id() skips the process-global os.environ write
when called from within a delegated_child_context(). The child's own
tools and subprocesses still resolve their id through the ContextVar
(task-local), while the parent's process-wide env keeps the parent's
session identity. Root agents (CLI, gateway, cron) retain both paths.
Adds 7 regression tests covering single child, concurrent children (8
parallel), parent-tool observation after construction, and root-agent
session rotation backward compatibility. All pass; ruff clean.
The on/off switch was the only knob. A pass fired after every completed turn,
absorbed exactly one exchange, and there was no way to ask for less. Since a
pass is also what breaks the prompt-cache prefix, "how often does it run" and
"how often do I pay a cache break" are the same question, and it had no answer.
Add `compression.micro_compact_every_n_turns` (default 1, clamped to >= 1). At 1
the behaviour is what it was; at 5 you get a fifth of the breaks and a fifth of
the reclaim rate. The counter advances per invocation rather than per committed
pass, so a turn that finds nothing to absorb still moves the cadence along and
cannot wedge it, and a bogus 0 or negative degrades to "every turn" instead of
silently disabling compaction.
Also expose `micro_compact_defrag_threshold_tokens`, which has been a hardcoded
attribute on the compressor with no path from config since it was added.
This does not give micro-compaction the prune's reclaim-size gate -- a pass
still commits whatever the single absorbed exchange saved. It makes the break
frequency tunable, which reaches the same end by absorbing less rather than by
waiting for a bigger win. The docs now say that plainly, including that a
reclaim threshold is the obvious follow-up and does not exist yet.
Tests cover the skip-until-due window, the cursor and prefix staying untouched
on skipped turns, the clamp, and that the feature is off unless enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review raised whether default-on can be reconciled with the prompt-cache
contract in AGENTS.md, which permits mutating past context only for context
compression and treats per-conversation caching as sacred. It cannot, and the
codebase already says so in its own words.
A micro-compaction pass rewrites already-sent history, so it invalidates the
cached prefix every turn rather than at an episodic boundary. That is the exact
cost the proactive prune gates against: `proactive_prune_min_reclaim_tokens`
exists, per its own config comment, to keep rewrites to "one big episodic break
instead of a tiny break every tool iteration." Micro-compaction has no
equivalent gate -- one exchange per turn means one break per turn, by design.
Default to off. An operator who wants the amortized stall can opt in with
`compression.micro_compact: true` and accept the tradeoff knowingly; nobody
inherits a per-turn cache break from installing an update.
Also register the key in config_defaults so it is discoverable and picked up by
the update path's new-options check -- it was previously read by agent_init but
declared nowhere -- and document the cache cost in docs/micro-compaction.md
instead of only the benefit. The measurements behind the feature (occupancy
plateau, zero batch compactions) never priced cache invalidation, and the doc
now says which numbers a reader would need to measure to justify enabling it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Batch compaction pauses a session for one large summarization once the
window fills. Micro-compaction spreads that cost out: after each completed
turn, `finalize_turn` folds the single oldest un-absorbed exchange
(assistant message plus its tool results) into a rolling summary, so the
work happens in small increments during post-turn idle time instead of one
long stall.
Mechanics:
- a cursor tracks the first message not yet absorbed, recovered from the
transcript's last summary marker when in-memory state is unavailable;
- protected head and tail windows are never touched, so the system prompt
and recent turns stay verbatim;
- the absorbed span is replaced by a marker carrying the usual
`_compressed_summary` metadata, so resume, handoff and `/compress`
treat it exactly like a batch summary;
- `archive_and_compact` keeps the session DB in step, otherwise the
append-only flush would leave the original rows active and a resume
would double-load both summary and originals;
- when the rolling summary itself passes a token threshold it is
defragged: re-summarized in one shot and the cursor jumps to the tail;
- an exchange the summarizer can't handle is retried a bounded number of
times, then skipped, so one poison exchange can't stall every turn.
Keep only the newest summary marker. The rolling summary is cumulative, so
each marker already contains everything the previous ones held; leaving them
stacked near-duplicate copies of the same text, each with its own heading and
end-marker scaffolding, and the transcript grew on every turn instead of
shrinking. Measured over six turns on a 12-exchange conversation with tool
output: 4104 -> 4797 tokens before, 4104 -> 2572 after.
Off switch: `compression.micro_compact: false` (default on).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Salvaged from #56085 (@Stoltemberg), rebased onto current main: sites
main had already converted (credential_pool, auxiliary_client MoA
paths, model_metadata, moa_loop, agent_runtime_helpers) resolve to
main's versions; the remaining ~29 read-only sites across 16 agent/
files swap to the no-deepcopy readonly loader (~135us saved per call).
Full per-site mutation audit performed (every enclosing function read,
escapes traced): 23 SAFE, 5 ESCAPES with read-only consumers, 1 UNSAFE
path (init_agent -> get_compatible_custom_providers -> normalizer
in-place alias writes) fixed by the preceding no-mutate commits, which
make the normalizer copy-safe for ALL callers.
The 1.0s sleep between sequential tool calls has been present since the
initial commit with no documented rationale. It sleeps between local
tool executions — the next LLM request goes out only after the whole
batch — so it rate-limits nothing, and the parallel read-only path
already runs with no delay. Every multi-tool turn pays (N-1) seconds
of dead time. Remove the sleep, the internal tool_delay plumbing, and
dead test assignments. AIAgent.__init__ keeps tool_delay as a
deprecated no-op keyword for one release so existing programmatic
callers construct cleanly; passing it emits a DeprecationWarning.
* Revert "fix(credits): remove the 'Grant spent · $X top-up left' notice"
This reverts commit 5dc6a14c14.
* fix(credits): reintroduce grant_spent behind an in-session crossing gate
The removed notice nagged because its condition is a steady state for
accounts living on top-up, the latch is per-session, and the cold-start seed
runs the policy at every session open — every session re-announced
'Grant spent · $X top-up left'. Reintroduce it gated so only a session that
WATCHES the grant run out announces:
- seen_grant_unspent crossing gate, mirroring seen_below_90: opens when the
session observes the grant meaningfully unspent (>= GRANT_UNSPENT_MIN_MICROS,
1 cent — portal-seeded states derive micros from float dollars and can carry
sub-cent residue where headers report exactly spent). Seeds never prime it.
- The gate guards only the show branch and is consumed by the announcement —
one announcement per crossing. Header flicker (used_fraction None and back)
clears the sticky line but cannot re-announce; a renewal re-opens the gate.
- new_credits_latch() centralizes the latch shape (agent build, lazy re-init,
and the policy test helper all build through it).
- Tests: steady-state-open stays silent (seed + policy seams), live crossing
announces once, flicker/renewal/residual cases locked; the rendering test
primes the gate and asserts its leg count so a gate regression cannot
silently shrink coverage.
Salvage of PR #52188. The original PR raised RuntimeError when LM Studio
load was rejected or unverifiable, which would abort agent startup on
transient network failures. Replace with logger.warning + fallback to
configured context length, preserving the old graceful-degradation behavior.
Direct tool HTTP calls already identified as Hermes-Agent, but the main
OpenAI-SDK chat path still sent OpenAI/Python. Set Hermes-Agent/<ver> for
api.x.ai clients (xai + xai-oauth) so normal text traffic is attributed correctly.
A /steer redirect during a thinking phase serialized the streamed
reasoning into the persisted assistant checkpoint ('Reasoning shown
before the interruption: ...'). An assistant turn exposing its own
chain-of-thought reads to Anthropic's output classifier as
reasoning-injection/prefill jailbreak, so every subsequent call on the
session deterministically returned 'Provider returned an empty
response' — and because the checkpoint is persisted and replayed, no
retry, nudge, or empty-recovery branch could ever escape it. Four
sessions were permanently bricked this way in the week of Jul 21-27
(42+ blocked calls; every reasoning-free checkpoint that week was
untouched — same mechanism as the prefill.json incident).
Class fix: streamed reasoning is now display-only state. The
_current_streamed_reasoning_text accumulator is removed entirely
(producer in _fire_reasoning_delta, resets, and init), so no future
path can serialize CoT into replayable content. The checkpoint keeps
only the visible response text; the model regenerates its reasoning on
the retried turn. Invariant documented in _apply_active_turn_redirect.
Regression tests: CoT never appears in either checkpoint shape,
reasoning-only interrupts produce a bare checkpoint, reasoning deltas
stay display-only.
Three mechanisms to detect and notify when gateway sessions stall silently:
1. Mid-turn activity heartbeats stamped to SessionDB so hermes sessions list
and hermes status show progress during long turns without new message rows.
2. Stall watchdog: when a busy session has pending inbound and the shared
activity clock is idle past agent.session_stall_timeout (default 300),
log a WARNING and notify the user once to try /new. Notify-only; does
not kill the turn.
3. Compaction timeout: fenceless compress_context callers get a progress-aware
host budget (compression.context_timeout_seconds default 120 idle,
compression.context_total_ceiling_seconds default 600 ceiling). On timeout,
cancel via commit fence, skip compaction without dropping messages, and
continue the turn.
Closes#72016 (slices 1-3; slice 4 cumulative SSE stream-retry deadline
remains a follow-up).
Cherry-picked from PR #72424 by @fangliquanflq.
is_truthy_value(..., default=False) and getattr(..., False) disagreed with
compression.in_place: true from #38763, so partial/failed config loads fell
back into rotation mode and re-armed the pre-lease drift path. Also report
compression.in_place in hermes dump overrides so stale false values are visible.
The default max_iterations/agent.max_turns budget was set when long
agentic runs were rare; complex tasks now routinely exceed 90 tool
calls. Raise the default to 500 across every surface that hardcodes
the fallback: AIAgent constructor, DEFAULT_CONFIG, CLI resolution
chain, gateway env bridge, cron scheduler, and TUI gateway. Explicit
user config values are unaffected (deep-merge preserves them; no
_config_version bump needed).
Docs (en + zh-Hans), CLI help text, tips, and pinned tests updated
to match.
Replace 3 duplicated entry_id resolution blocks (try/except +
entry_id_for_api_key + fallback to None) in agent_init.py,
chat_completion_helpers.py, and switch_model with a single
sync_credential_pool_entry_id(agent) function in agent_runtime_helpers.
Follow-up to #70323.
Track the selected credential by stable pool entry ID so token refreshes and shared cursor movement cannot detach failures from the entry that issued them. Stop unmatched single-entry pools from reporting a no-op rotation as successful recovery.
Co-authored-by: Maxim Esipov <maksesipov@gmail.com>
Keep MoA reference display events off the machine-readable -Q stdout
surface (platform=cli with tool_progress_mode=off) while preserving them
everywhere else. Extracts the relay into module-level helpers so the
policy is testable.
Salvaged from #67334.
Adds per-reference progress events and a phase-transition marker to the
MoA display pipeline so TUI / CLI / desktop surfaces can render a status
bar like `MOA: 2/3 refs done` and surface which phase (reference vs
aggregator) is currently active.
- `moa.progress` — fired once per reference completion with
`refs_done`, `refs_total`, and the source label
- `moa.phase` — fired on phase transitions (currently the single
`phase="aggregator"` transition once the fan-out
finishes)
Plumbed through the existing `reference_callback` →
`tool_progress_callback` → gateway path; no new UI surface. The legacy
`moa.reference` / `moa.aggregating` events are unchanged for backwards
compatibility.
AI-assisted fix by https://github.com/SquabbyZ/peaks-loop
Follow-up to the salvaged core of #53802: a naive MoAClient(preset) rebuild
restores a working facade but silently drops the reference_callback relay
wired in agent_init, so moa.reference / moa.aggregating display events stop
reaching every frontend for the rest of the session.
Introduce agent.moa_loop.build_moa_facade(agent, preset) as the single
construction point for the MoA facade and use it at:
- initial client construction (agent_init.py)
- turn-start fallback restore (restore_primary_runtime)
- transient transport recovery (try_recover_primary_transport — previously
fell through to _create_openai_client with MoA's empty client_kwargs and
died with 'api_key client option must be set')
- mid-session model switches (switch_model)
The relay reads agent.tool_progress_callback at emit time, so callbacks
attached after construction are picked up automatically.
Adds test_moa_restored_facade_still_emits_reference_events covering event
delivery through a restored facade.
Follow-up fixes on top of the salvaged #22566 mechanism:
- N-collector now counts only REAL actionable user turns via
_is_actionable_user_turn + _is_synthetic_compression_user_turn —
the same filter pair _find_last_user_message_idx uses post-#69291.
The contributor's bare role=='user' + _is_context_summary_content
check let blank platform echoes and continuation/todo rows consume
N slots, silently degrading the guarantee.
- Default flipped 3 -> 1 (behavior-preserving): a default of 3 was
measured to change the tail cut on transcripts whose budget covers
only the last turn. min_tail_user_messages=1 delegates to the
existing single-user anchor; N>1 is opt-in, and the call site is
gated so the default path is byte-identical to main.
- Hardened config parse in agent_init (bool rejected, fractional
floats rejected, floor 1) matching the max_attempts parser shape.
- Wired the recurring external-PR config gaps: hermes_cli/config.py
DEFAULT_CONFIG + cli-config.yaml.example (PR only had cli.py).
- Regression tests: blank echoes / synthetic rows don't count toward
N; tool-call/result pairs never split by the N-boundary (no-orphan
both directions); N-guarantee wins over tail_token_budget and the
_MAX_TAIL_MESSAGE_FLOOR (floor is a minimum, not a cap); default
parity pin; DEFAULT_CONFIG pin.
Add _ensure_last_n_user_messages_in_tail to guarantee the last N user
messages survive compression in the uncompressed tail, with surrounding
assistant/tool context preserved.
- Add min_tail_user_messages parameter (default 3) to ContextCompressor
- New _ensure_last_n_user_messages_in_tail method generalizes single-user protection
- Skip context-summary handoff banners when counting user messages
- User messages are clean boundaries — skip _align_boundary_backward
- Wire through cli.py, agent_init.py, and gateway cache busting keys
Config:
compression:
min_tail_user_messages: 3
Co-Authored-By: Claude <noreply@anthropic.com>
The phase-1 tool-result prune only runs inside compress(), which fires
near 50% of the context window, so it never triggers on large-window
models; old tool outputs then ride in history and are re-sent every turn.
Add prune_tool_results_only(): the same no-LLM prune on a separate, low
proactive_prune_tokens trigger, run as an elif to the compression branch.
Opt-in (default 0), protects the recent tail by message count.
Add the method to the ContextEngine base as a no-op default so pluggable
engines inherit it safely (the post-tool-call path never AttributeErrors on
a non-built-in engine); the built-in compressor supplies the real prune.
Register both keys under the top-level compression config with defaults and
document them.
A follow-up sent while the model is still generating previously ended the
turn: Hermes kept only the visible partial text (reasoning was display-only),
cleared the loop, and replayed the message as a fresh next turn. If the
correction referred to something that only appeared in the thinking stream,
the model no longer had that context.
Add `AIAgent.redirect(text)`: a corrective interrupt distinct from a hard
stop. It cancels only the in-flight model request (not tool workers or child
agents), stashes the correction under a lock shared with `interrupt()` so a
concurrent `/stop` always wins, and lets the loop rebuild the same logical
iteration. `_apply_active_turn_redirect()` checkpoints the reasoning that was
actually shown to the user plus any visible partial text as an ordinary
assistant message, then appends the correction as a real user turn — never
replaying incomplete signed/encrypted provider reasoning, and keeping strict
role alternation and prompt-cache stability intact. During tool execution it
degrades to `steer()` so a running tool finishes at a safe boundary.
`_fire_reasoning_delta` now only records reasoning that a display callback
actually consumed, so `show_reasoning: false` never leaks hidden provider
thinking into the persisted transcript.
Long-lived sessions (e.g. a Telegram thread resumed over hours/days)
accumulate a large context that the existing size-based threshold only
trims once it crosses `threshold × context_window`. Until then every
turn re-reads the full history, which on large-context models can mean
hundreds of K of cache-read tokens per call even across long idle gaps.
Add a time-based trigger that complements (does not replace) the size
threshold: when a session resumes after `compression.idle_compact_after_seconds`
of inactivity, compact the accumulated history up front, before the first
reply. Disabled by default (0), so existing behaviour is unchanged.
The trigger reuses `_last_activity_ts` (the last time the turn loop did
work) to measure the idle gap at turn start, gates the token estimate
behind a cheap gap pre-check, and skips compaction when the context is
already at/below the post-compression target (threshold × target_ratio)
so a short idle thread never pays for a summarization that saves nothing.
It also defers to an active compression-failure cooldown.
The decision is factored into a pure predicate, `_should_idle_compact`,
which is unit-tested without a live agent.
Add compression.threshold_tokens config option that sets an absolute
token cap for auto-compaction. When configured alongside the existing
ratio-based threshold, the effective trigger point is the lower of the
two, so compression never fires later than the user's preferred token
count regardless of which model is active.
This solves the problem where switching between models with different
context windows (e.g. 1M → 400K) shifts the absolute trigger point,
causing premature or delayed compression.
Rework from PR #24279 addressing sweeper feedback:
- The cap is now a first-class compressor configuration value
(threshold_tokens_cap parameter on ContextCompressor.__init__),
not a post-construction patch on the live instance.
- Applied in both __init__ and update_model() so it survives model
switches and fallback activations (the old approach was undone by
update_model() restoring _configured_threshold_percent).
- Clamped to the model's context length so a cap above the window is
a no-op (ratio-based threshold wins).
- Works with max_tokens output-token reservations.
- Added 9 tests covering cap-vs-ratio selection, model switch survival,
context-length clamping, max_tokens interaction, and invalid values.
- Updated user-facing configuration docs.
- Removed unrelated background-review/curator/Honcho changes (main
already contains background-review memory isolation in 973f27e95).
Config example:
compression:
threshold: 0.50
threshold_tokens: 200000 # never compress later than 200K tokens
Follow-up to the salvaged contributor commit, closing the three gaps
flagged in the sweeper review:
1. Init ordering: assign compression.model_thresholds to a selected
plugin context engine BEFORE the initial update_model() call in
agent_init.py, so the initial model's override applies from init
(previously it only took effect after the first /model switch).
Base-class ContextEngine.update_model() now snapshots the
pre-override percent once so repeated switches fall back to the
engine's configured threshold, not a previous model's override.
2. DEFAULT_CONFIG: add compression.model_thresholds (empty map) to
hermes_cli/config.py — additive key, no _config_version bump.
3. Docs: document the key in
website/docs/developer-guide/context-compression-and-caching.md
(yaml example, parameter table, dedicated section) and update the
plugin-boundary note in context-engine-plugin.md to state the
explicit context-engine contract for model_thresholds.
Adds tests/run_agent/test_per_model_threshold_init_ordering.py:
plugin-engine AIAgent init regression (override applies at init,
empty map unchanged), DEFAULT_CONFIG key presence, floor interaction
on the model-switch path (override below the small-context floor is
raised to the floor; above the floor wins), and base-class config
snapshot across repeated switches. Also maps @bennybuoy in
contributors/emails/.
Addresses teknium1 review feedback on PR #60781:
1. Gateway cache invalidation: added ('compression', 'model_thresholds')
to _CACHE_BUSTING_CONFIG_KEYS so a live config edit to the map
invalidates the cached compressor (previously kept stale thresholds).
2. Integrated resolver with small-context floor: per-model overrides are
resolved FIRST, then the existing 75% floor for <512K models is applied
on top. The floor is no longer replaced — it stacks. An override below
75% on a small-context model still gets floored to 75% (raise-only);
an override above 75% wins.
3. Clean rebase on upstream main — no unrelated deletions or anti-thrashing
changes. Only the per-model threshold feature is added.
Changes:
- resolve_model_threshold() module-level helper (longest substring match)
- ContextCompressor.__init__ accepts model_thresholds dict
- _base_threshold_percent stores the per-model resolved value
- _config_threshold_percent stores the raw config value (fallback base)
- update_model() re-resolves on /model switch, falls back to config value
- ContextEngine base class update_model() applies overrides for plugin engines
- agent_init.py reads compression.model_thresholds from config, passes to ctor
- gateway/run.py cache busting key added
- cli-config.yaml.example documents the feature
- 17 tests covering resolve helper, compressor init (large/small context,
override above/below floor), update_model (re-resolve, fallback), base class
Co-authored-by: Copilot <copilot@github.com>
skip_memory=True was meant to skip the external memory *provider* for flush/
background agents, but it also suppressed creation of the built-in file-backed
MemoryStore. When a caller still enables the "memory" toolset, the memory tool
dispatched with store=None and every call failed with "Memory is not available",
silently losing the main automatic memory-capture path.
Now the built-in store is created whenever memory is enabled in config OR the
memory toolset is explicitly enabled, while the external-provider block stays
gated on skip_memory (preserving flush-agent intent).
Follow-up to the salvaged #64010 (Kenmege) and #63870 (dombejar) commits,
making one resolved compression.max_attempts cap govern ALL per-turn
compression attempt sites:
- conversation_loop: resolve max_compression_attempts ONCE at turn start
(it was previously re-resolved inside the API-call loop) and route the
pre-API pressure gate through it — that gate still hardcoded
'compression_attempts < 3' and logged 'attempt=%s/3'.
- conversation_loop: the salvaged post-tool compaction gate now uses the
resolved cap instead of a hardcoded 3.
- turn_context: the preflight compaction loop was 'for _pass in range(3)';
it now sizes itself from the same resolved cap.
- agent_init: harden the max_attempts parser — reject booleans (bool
subclasses int; 'true' would coerce to 1), reject fractional floats
instead of truncating them, keep accepting integral floats and numeric
strings; anything else falls back to 3 (floor 1, ceiling 10 unchanged).
- tests: replace #63870's inspect.getsource source-shape test with
behavioral loop tests (post-tool compaction fires <= cap times per turn,
shares its budget with the pre-API gate, resets between turns); add an
e2e test proving a 4th preflight pass runs at config cap=6 while the
unset default still stops at 3; extend the #64010 config tests with the
bool/float parser semantics.
Salvages #64010 by @Kenmege and #63870 by @dombejar.
The conversation loop hardcodes max_compression_attempts = 3. Sessions
that legitimately need more rounds are stranded: on a restart history
reload, incompressible tool schemas can keep the per-request estimate
above the compressor threshold even though the message floor compresses
correctly, so three rounds cannot clear it and the turn dies with
"Context length exceeded: max compression attempts (3) reached" — the
same failure class as #62605, where the rough estimate similarly leaves
3 retries short.
Make the cap a config key, compression.max_attempts:
- default 3 = identical to today, so an unset key is behavior-neutral;
- parsed and validated in agent_init alongside the other compression.*
keys (>= 1, hard-capped at 10, non-integer values fall back to 3),
attached as agent.max_compression_attempts;
- the loop reads it via getattr(agent, "max_compression_attempts", 3),
so objects without the attribute keep the prior behavior;
- documented in the DEFAULT_CONFIG compression block.
Tests pin the parse/validate/attach seam: default preserved, custom
value honored, floor and ceiling enforced, garbage tolerated, and the
loop-side getattr degradation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>