- aux summary call on main intentionally omits max_tokens; use .get() in the
telemetry hook (and widen the param type) so the hook never breaks the call
- update test expectation: aux_output_reservation is None on main
- record no_progress failure_class in the no-progress boundary branch
Follow-up for salvaged PR #60444.
The summary_idx head-copy skip (from #69302) dropped the entire merged
handoff message, deleting the genuine prior-tail user content that
#47274's _strip_context_summary_handoff_message correctly unwraps.
Strip handles both shapes: standalone handoffs drop, merged handoffs
keep their real content. Caught by
test_recompression_of_current_merged_handoff_preserves_prior_tail_once
when both PRs landed together.
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
Compaction summaries persist across sessions and re-enter every subsequent
summarizer prompt, but every redact_sensitive_text() call in
context_compressor.py used default mode: a no-op under
security.redact_secrets:false, and opaque OAuth-callback / URL-userinfo
credentials passed through even when enabled. The stored _previous_summary
also re-entered the iterative-update prompt unredacted.
Add _redact_compaction_text() — redact_sensitive_text(force=True,
redact_url_credentials=True) — and thread it through all compaction text
boundaries: serializer input (content + tool args), deterministic fallback
summary, summarizer LLM output, manual + auto focus topics, the latest-user
task snapshot, and _previous_summary re-entry.
Note: force=True at this boundary intentionally overrides
security.redact_secrets:false — that opt-out targets live tool output, not
persisted summaries.
Salvages the compaction half of #49556 (the redact.py strict-URL half
landed independently via 75af6dc57/62a00a739). Addresses #43666 item 2.
Co-authored-by: AndrewMoryakov <topazd2@gmail.com>
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>
Two compounding defects in the holographic memory provider (#57682):
1. The on_session_end gate used plain truthiness on auto_extract, but the
plugin's own config schema declares it as a string enum with default
"false" — and not "false" is False, so extraction ran for users who
had it configured off. Coerce with the shared utils.is_truthy_value
(same fix class as the merged byterover no-op fix).
2. _auto_extract_facts scanned every role=user message. Context-compaction
handoff summaries can be inserted as role=user messages and their prose
reliably matches the decision patterns (we decided/agreed, the project
uses), so the compactor's own output was persisted as a durable project
fact on every rollover following a compaction — recreated even after
manual deletion.
Adds agent.context_compressor.is_compaction_summary_message(), a public
helper that prefers the in-process COMPRESSED_SUMMARY_METADATA_KEY marker
and falls back to _is_context_summary_content() (covers merged-into-tail
and historical prefixes), since the metadata key is stripped by wire
sanitizers and doesn't survive all session-store round-trips. The plugin
skips summary messages before pattern matching.
Fixes#57682
Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize.
Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion.
The MemoryProvider.on_pre_compress() hook returns text that providers
want preserved in the compression summary, but run_agent.py discarded
the return value. Additionally, compress() and _generate_summary() had
no mechanism to accept this context.
- Capture on_pre_compress() return value in run_agent.py
- Add memory_context parameter to compress() and _generate_summary()
- Inject memory provider insights into summarization prompts
Fixes all MemoryProvider plugins that return context from
on_pre_compress() (currently silently broken for every plugin).
_find_tail_cut_by_tokens aligns cut_idx away from tool-call/result
boundaries (_align_boundary_backward), and both tail anchors re-align after
moving it. The final statement then raised the result to head_end + 1 so
compression always claims at least one message — without that floor the
caller's compress_start >= compress_end guard turns the pass into a no-op
that re-runs forever.
That raise discarded the alignment. When the floor landed inside a tool
group, the parent assistant(tool_calls) fell in the summarised region while
its tool results started the tail, and _sanitize_tool_pairs dropped those
orphans outright — so the tool output was neither summarised nor kept. It
vanished. That is exactly the silent loss _align_boundary_backward's own
docstring says the alignment exists to prevent.
Two back-to-back tool calls are enough to trigger it on default settings
(protect_first_n=3):
system, assistant(call_1), tool, tool, assistant(call_2), tool
aligned cut = 4 (keeps call_2's group together)
returned cut = 5 (floor overrode it)
summarised region = [assistant(call_2)]
tail = [tool(call_2)] -> orphan -> dropped
Sweeping every well-formed block layout up to length 6 (21840 transcripts),
5623 of them — 26% — split a call/result pair this way.
Re-align FORWARD after applying the floor. Forward, never backward: pulling
back would hand return the message the floor just claimed and reopen the
no-op loop. Sliding forward instead moves the cut past the end of the group,
so the whole call/result pair is summarised together and nothing is
orphaned. The same sweep reports 0 violations after the change, and the
progress guarantee is pinned by its own test.
The ineffective-compression counter is not durable; when it is the sole
reason the gate is blocked there is nothing in the DB that could unblock
it, so re-reading the guard rows on every gate check for the rest of the
session is pure waste. Guard test pins the no-DB-touch behavior.
Follow-up on the salvaged #64511 commit:
- _automatic_compression_blocked() now refreshes durable guard state
(cooldown + fallback streak) when — and only when — the in-memory
snapshot says blocked, then re-evaluates. The should_compress()
pre-gates (preflight/turn paths) consult this before ever reaching
compress_context, and a stale fallback streak has no expiry timer, so
without a gate-level refresh a cleared durable row could never unblock
a prebound agent. The unblocked hot path pays no DB reads.
- A refresh that finds no durable cooldown row no longer clears a live
local cooldown whose DB persist FAILED (_cooldown_persist_failed):
an empty row is not evidence another agent cleared it, and honouring
it would reopen the #11529 thrash window. A successful durable
round-trip (record or read) makes the DB authoritative again.
- Guard tests for both directions (red on the pre-fix code), including
a hot-path test asserting the unblocked gate never touches the DB.
Follow-up hardening on top of the salvaged #66637 commits:
- _insert_real_user_anchor could place the restored human turn directly
next to user-role scaffolding (index-0 insert before a leading synthetic
user turn, or a scaffolding-only transcript), breaking the strict
alternation contract (#55677). Restoration now merges into trailing
scaffolding (anchor text leads, synthetic flags cleared) and appends
after a user-role compaction summary instead of inserting adjacent.
- _is_real_user_message now also rejects user-role compaction summaries
(the compressor pins the summary to role=user when the tail opens with
an assistant turn), so a summary can no longer satisfy the human-anchor
check and skip restoration.
- _latest_user_task_snapshot reuses the same real-user predicate, so the
deterministic task snapshot can no longer anchor on todo snapshots,
truncation notices, or background-process reports.
- The Historical Task Snapshot rewrite keeps the section terminated with
a blank line; the previous replacement consumed the boundary newlines,
gluing the next '## ' heading mid-line and deleting all later sections
on the next iterative compaction.
- Drop _length_continuation_synthetic (no producer anywhere).
- AUTHOR_MAP entry for enzo-adami.
Deduplicates the sidecar handling across 9 sites:
- substitute_api_content(): 2 API-bound pop+substitute sites
(chat_completion_helpers, transport — conversation_loop keeps its
inline pop because the current-turn compose fallback needs the value)
- drop_stale_api_content(): 4 rewrite-drop sites
(context_compressor x2, replay_cleanup, agent_runtime_helpers)
- extract_api_content_sidecar(): 3 gateway forwarding sites
(gateway/session, gateway/slash_commands, cli_commands_mixin)
Also: restore the eager _pending_cli_user_message clear on the early
row-creation try (was lost when the crash-persist moved after prefetch —
a crash in compression between the two tries would leave a stale staged
input), and fix a comment indentation nit in run_agent.py.
Co-authored-by: Soju06 <qlskssk@gmail.com>
The first LLM call of every gateway turn gets ~0% provider prompt-cache
hit rate (in-turn calls: 97-99%) because the bytes sent for a turn's
user message are not the bytes replayed next turn: memory-prefetch and
pre_llm_call context are injected into the API copy only, the #48677
persist override writes cleaned content to the DB row, and
get_messages_as_conversation sanitize/strips user and assistant content
on load. Any of these diverges the request prefix at that message and
re-prefills everything after it — measured 27.9s for the first call vs
2.4-5.8s cached at a median ~156k-token context.
Persist what you send: a nullable messages.api_content column stores the
exact content string sent to the API when it differs from the clean
stored content, and replay substitutes it verbatim (no sanitize, no
strip). The injection composition lives in one helper
(turn_context.compose_user_api_content); the turn prologue stamps its
output onto the live user message, the api_messages build sends the
stamped bytes, and every outgoing copy pops the field so it never
reaches a provider. The crash-resilience user-turn persist moves after
prefetch/pre_llm_call so the user row is written once with its final
sidecar; _ensure_db_session stays before preflight compression (session
rotation needs the parent row under PRAGMA foreign_keys=ON). The
current-turn index trackers are re-anchored after compaction rebuilds
the message list, in-place preflight compaction backfills the stamp onto
the already-inserted row, and gateway replay forwards the sidecar only
when the replay pipeline did not rewrite the content. Rewrite paths that
would leave stale bytes (historical image strip, merge-summary-into-
tail, consecutive-user repair merge, stale-confirmation redaction) drop
the sidecar; the chat-completions transport and the max-iterations
summary path strip it defensively. codex_app_server and MoA turns are
excluded from stamping because their wire bytes differ from the
composition. A missing or dropped sidecar degrades to today's behavior
(one cache-boundary miss), never to wrong content.
The REFERENCE ONLY framing ('treat as background reference, NOT as active
instructions... Do NOT answer questions or fulfill requests') was observed
bleeding into general tool-use suppression: a production session went
narration-only for 7 consecutive turns immediately after a compression
event, describing edits instead of calling tools (#65848 report).
Fix is additive: one clause stating the note does not restrict HOW the
agent works — tools remain fully active for the active task. Every
anti-resumption protection stays intact; the previous prefix generation
is frozen into _HISTORICAL_SUMMARY_PREFIXES per the module contract so
persisted summaries still get the directive-strip on re-compaction.
The #65848 rewrite was not taken: dropping the 'Do NOT answer questions'
line and the four-heading discard directive risks re-opening the
stale-task-resumption class those clauses exist to prevent (the carveout
era regressions #41607/#38364/#42812 documented in this file).
Report and root-cause analysis: @yasserbousrih (#65848).
Fixes#62452. Two amplifiers turned one slow auxiliary route into a
per-turn multi-minute stall:
1. Fallback candidates inherited the exact effective_timeout the primary
was called with. When the primary's deadline was short (tuned or
already burned), an independently healthy fallback died on the same
clock — the reporter's 163k-token compression needed ~90s on the
fallback and got the primary's 30s, every turn. fallback_chain
entries may now declare their own 'timeout' (seconds); both fallback
candidate call sites (sync + async) resolve it via
_fallback_entry_timeout, label-scoped so only configured-chain
candidates are affected. No entry timeout → task-level timeout,
preserving existing behavior.
2. A session whose transcript structurally cannot be summarized within
the deadline re-attempted every 60s, re-burning the full timeout on
every subsequent turn. Consecutive timeout-class failures now
escalate the cooldown 60s → 300s → 900s (capped); any successful
summary or session reset clears the streak. Timeout classification
takes precedence over the streaming-closed 30s rung ('timed out'
also matches _is_connection_error) and now recognizes the SDK's
'Request timed out.' phrasing.
Fail-safe behavior is unchanged: all messages are preserved when every
candidate fails; the cooldown only spaces out retries.
Follow-up on @AlexFucuson9's cherry-picked multimodal flattening:
- http(s) image parts render as '[image: <url>]' so the summary keeps a
referenceable handle after compaction (base64 data: URLs still
collapse to '[image]' — no reusable reference, and leaking them is
the bug being fixed)
- unknown part types (document, future shapes) render as '[<type>]'
instead of being silently dropped
- test docstrings corrected: the original crash premise is stale
(redact_sensitive_text grew str() coercion in #52147); the live bug
is repr-noise + base64 leakage into the summarizer input
msg.get('content') can return a list of parts for multimodal messages
(containing text, images, etc.). The old code passed this list directly
to redact_sensitive_text(text: str), which raised AttributeError on
list.replace(), causing context compression to fail entirely for any
session with attached images.
Fix: detect list content and extract text parts before redacting.
Image parts are replaced with '[image]' placeholder.
_summarize_tool_result() built the web_extract summary straight from the
first `urls` entry. When web_search results are forwarded into web_extract
(a common chain), that entry is a dict ({"url"/"href": ...}) rather than a
URL string. With 2+ URLs the `url_desc += " (+N more)"` step then raised
`TypeError: unsupported operand type(s) for +=: 'dict' and 'str'`, aborting
the pre-compression pruning pass; with a single URL the raw dict repr
leaked into the summary text.
Unwrap the URL from dict entries before use — mirroring the existing
web_extract handling in agent/display.py (`_display_url`),
tools/web_tools.py (`_web_extract_url`) and acp_adapter/tools.py
(`build_tool_title`) — so `url_desc` is always a string and the
concatenation is always str + str.
Adds regression tests covering multiple/single dict URLs, the href key,
malformed dicts, and the plain-string path.
Consolidation follow-up on top of @liuwei666888's compressor fix (#52431)
and @Frowtek's ACP render guard (#62588) — completes the class fix across
every display-layer consumer of tool-call arguments:
- agent/context_compressor.py: wrap _summarize_tool_result in a
never-raises backstop (same pattern as get_cute_tool_message and the
ACP guard). The per-branch _str_arg coercions keep summaries
informative; the wrapper guarantees compression can never crash-loop
on a summary branch we didn't anticipate. Also reject non-dict parsed
args (a JSON list/scalar would crash every args.get call).
- agent/display.py: build_tool_preview's process branch sliced
session_id/data without coercion — crashed build_tool_preview and
build_tool_label on the live tool-progress callback (probed live:
TypeError 'int' object is not subscriptable). Coerce like the
sibling branches.
- tests: backstop fuzz matrix (16 tools x 6 hostile value shapes),
fallback-shape contracts, display process-preview regressions,
non-dict args guard.
When LLMs return non-string parameter values (e.g. bool, int) in tool call
arguments, _summarize_tool_result() crashes with TypeError because it calls
len(), .count(), or slicing directly on args.get() return values.
This causes an infinite crash loop in the TUI — context compression
triggers on session resume, which crashes, which restarts, which triggers
compression again.
Add _str_arg() helper that coerces any value to str, and use it in all 5
vulnerable call sites:
- terminal: len(cmd)
- write_file: content.count()
- delegate_task: len(goal)
- execute_code: len(code)
- vision_analyze: question[:50]
Third correction, and the load-bearing one. The previous commit put the
"did compaction clear the threshold?" verdict inside should_compress(). But
conversation_loop calls should_compress() TWICE per turn with two different
measures (turn_context.py / conversation_loop.py:1033 and :4789):
* pre-API : request_pressure_tokens -- a rough estimate that can dip BELOW
the threshold
* post-API: real prompt tokens -- which stay above it
So the rough reading reset the strike every turn and the loop never stopped.
Reproduced: 8 compactions in 8 turns under the real two-call pattern, even
with the previous fix applied. (My earlier repro only called should_compress()
once per turn, which is why it looked contained.)
Move the verdict to update_from_response(), the one place that sees the
provider's real prompt_tokens for the just-compacted conversation, guarded by
the existing awaiting_real_usage_after_compression flag so it fires exactly
once per compaction. Real-vs-real: it cannot be fooled by a rough sub-threshold
reading, and (from the previous commit's lesson) never subtracts an estimate
from a real count. should_compress() goes back to a plain threshold test plus
the pre-existing cooldown and anti-thrash guards.
New test test_rough_preflight_reading_does_not_reopen_the_loop drives the real
two-call-per-turn pattern and fails on the prior should_compress()-based commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the previous commit, whose futility check was unsound:
incompressible_floor = max(0, display_tokens - pre_estimate)
`display_tokens` is the provider's real prompt count; `pre_estimate` is
`estimate_messages_tokens_rough(messages)`. Subtracting an estimate from a real
count folds the tokenizer skew into "floor" and misreads it as incompressible
overhead. With a 1.6x skew on a 200K window (threshold 150K, true floor 30K):
rough_msgs=253,804 real_prompt=436,086
computed floor = 182,282 <-- mostly skew; exceeds the threshold
after compaction: 401 -> 77 msgs, real prompt = 106,361 (CLEARS 150,000)
verdict: ineffective_count = 1 <-- false positive
Two such passes would permanently disable compaction on a healthy session --
worse than the loop this PR set out to fix.
Move the check into should_compress(), where both sides of the comparison are
the caller's own token count:
* prompt under the threshold -> not thrashing; reset the counter
* a compaction just ran and we are STILL over -> one strike
Real-vs-real, so tokenizer skew can never be mistaken for a floor, and nothing
subtracts an estimate from a real count. compress() now only ever increments the
counter; the reset lives with the one measure the trigger uses.
Adds `test_no_false_positive_under_tokenizer_skew` (the case above) and
`test_counter_resets_once_the_prompt_fits_again` (one failed pass must not
disable compaction forever). Against upstream, 5 of the 7 cases fail; the 2 that
pass are the regression guards, which is the intended shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`should_compress()` documents anti-thrashing protection ("if the last two
compressions each saved less than 10%, skip compression to avoid infinite
loops"). In practice `_ineffective_compression_count` reset on every pass,
so the guard was dead code and a mis-sized context window presented as a
hung CLI instead of a warning.
Two defects:
1. Mixed measurement bases. Effectiveness was
`(current_tokens - estimate(compressed)) / current_tokens`, where
`current_tokens` is the provider's FULL prompt (system prompt + tool
schemas + messages) but `estimate(compressed)` covers messages only.
Every compaction therefore reported ~96% savings and reset the counter.
Savings is now scored messages-vs-messages.
2. Message shrinkage is the wrong yardstick. `should_compress()` trips on
the full prompt, but compaction can only shrink messages -- the system
prompt and tool schemas are an incompressible floor. When that floor
alone meets the threshold, each pass shrinks messages by a healthy
margin, legitimately resets the counter, and still leaves the prompt
over the line; the next turn compacts again, forever. Observed in the
wild: 45+ consecutive compactions, one auxiliary-LLM call each, zero
progress. Effectiveness is now scored against the goal -- did the
projected prompt get under the threshold? -- and a futile pass warns
with the numbers that prove it.
Also record an ineffective pass on the "only N messages (need > M)" early
return, which previously returned the transcript unchanged without moving
any anti-thrash state -- the same class of bug the neighbouring
"no compressable window" branch was already fixed for.
Tests: 4 of the 5 new cases fail on main and pass here; the fifth pins
that effective compaction still resets the counter (121 -> 15 messages,
88.9% savings).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sessions on sub-512K-context models were spending most of their wall-clock
re-summarizing: the 50% trigger left too little post-compaction headroom
(the incompressible floor — system prompt, tool schemas, protected tail,
rolling summary — ate most of the reclaimed space), so compaction re-fired
every 1-2 turns. Three compounding defects fixed:
- Threshold floor: models with context windows below 512K now trigger at
>=75% of the window (raise-only — a higher configured value or per-model
autoraise like Codex gpt-5.5's 85% always wins). Re-derived on
update_model() in both directions.
- No max_tokens on the summary call: the summary budget is prompt guidance
only ("Target ~N tokens"). The wire cap truncated summaries mid-section
on the Anthropic Messages / NVIDIA NIM paths (thinking models burn the
cap on reasoning first), yielding truncated or thinking-only summaries
and compaction loops. Summary token ceiling lowered 12K -> 10K to keep
the guidance within the intended 1K-10K envelope.
- Reasoning traces excluded end-to-end: inline <think>/<reasoning> blocks
are now stripped from assistant content before serialization to the
summarizer, and from the summarizer's own output before the summary is
stored (previously a thinking summarizer model's trace was persisted in
_previous_summary and re-fed into every iterative update, compounding
bloat). Native reasoning fields were already excluded.
Verified E2E with real imports against a temp HERMES_HOME: threshold table
across 64K-1M windows, override interactions (user 0.85 wins, spark 0.70
raised, gpt-5.5 0.85 kept), full compress() round-trip with a thinking
summarizer, and wire-kwargs capture proving no max_tokens is sent.
Compression could produce a transcript with ZERO user-role messages,
which OpenAI-compatible backends (vLLM/Qwen) reject with a non-retryable
`400 No user query found in messages`. This crashes `hermes kanban`
workers unrecoverably: every resume replays the same poisoned history and
fails on the very first request after a successful compaction.
The existing #52160 guard pins the handoff summary to role="user" only
when `last_head_role == "system"` — i.e. when the system prompt sits
inside `messages` (the gateway `/compress` path). The main
auto-compression path prepends the system prompt at request-build time,
so the list handed to `compress()` starts with a user/assistant turn,
`last_head_role` defaults to "user", and the summary is emitted as
role="assistant". A kanban worker seeded with a single short
`"work kanban task <id>"` prompt followed by nothing but assistant/tool
turns therefore ends up user-less once that early turn is summarised.
Generalise the guard: when no user-role message survives in the protected
head or the preserved tail, force the summary to carry role="user" so the
request always has at least one user turn. When a user does survive
(e.g. in the tail), the guard does not fire, so alternation is preserved.
Fixes#58753.
Follow-up to the per-site strips from the review gate. The two copy-site
strips are correct but positional — a copy site added after the assembly
loops would re-leak _db_persisted into the child-session flush. Add a single
terminal sweep (_strip_persistence_markers) run once on the fully-assembled
compressed list so the invariant 'no compacted message leaves compress()
carrying a persistence marker' is structural, not dependent on copy-site order.
- agent/context_compressor.py: _strip_persistence_markers() called before
compress() returns; helper docstring notes the sweep is the authoritative guard
- tests/agent/test_context_compressor.py: structural regression — neuter the
per-site helper to a leaking copy, assert the terminal sweep still strips
- tests/run_agent/test_compression_persistence.py: pin the fixture assumption
behind the exact-equality row-count assertion