Skip to content

fix(compression): break the protected-tail dead-end on oversized messages - #69830

Merged
teknium1 merged 3 commits into
mainfrom
salvage/61952-oversized-tail
Jul 23, 2026
Merged

fix(compression): break the protected-tail dead-end on oversized messages#69830
teknium1 merged 3 commits into
mainfrom
salvage/61952-oversized-tail

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Breaks the #61932 "Cannot compress further" dead-end: when the protected tail (token budget + the 8-message floor) alone holds oversized recent messages, compress_start >= compress_end made every compression pass a pure no-op — the transcript stayed over the context window and the retry loop exhausted. Root cause: _prune_old_tool_results passed the uncapped protect_last_n (default 20) into the prune boundary while tail-cut already bounds it at _MAX_TAIL_MESSAGE_FLOOR, and there was no path at all that could shrink oversized tool bodies inside the protected region.

Changes

  • agent/context_compressor.py:
    • Cap the prune path's message-count floor at _MAX_TAIL_MESSAGE_FLOOR (same bound tail-cut already uses at _find_tail_cut_by_tokens), so a default protect_last_n=20 cannot freeze a bulky recent tool run against pruning.
    • New Pass 4 pressure demotion: when the protected region still exceeds the soft tail budget (tail_token_budget * 1.5), demote large completed tool/file bodies inside that region — oldest first, keeping a _PRESSURE_KEEP_RECENT_MESSAGES=3 recent floor and the active user ask verbatim. Escalation demotes everything except the single newest tool body; absolute last resort summarizes even that (one 200KB read can exceed the soft budget alone). Demotion rewrites content in place and never touches tool_call_ids, so tool_call/tool_result pairing is preserved.
    • Pass 2/3 refactored into _demote_tool_result_at / _truncate_tool_call_args_at helpers reused by the pressure pass (behavior unchanged outside pressure).
  • tests/agent/test_protected_tail_pressure_61932.py: contributor's 5 tests (pressure demotion, last-resort branch incl. the sweeper-requested tool_call_id assertion, full-compress escape, light-tail no-op) + a new regression test pinning the exact [Regression]: auto-compression still reaches Cannot compress further when protected recent tail is large (Desktop/openai-codex) #61932 dead-end shape: head + an 8-message all-oversized tail that previously returned unchanged now compresses below the context window in one pass, with no orphaned tool calls/results.

Validation

Before (origin/main) After
All-oversized protected tail, 128K ctx (200,248 tok) 150,330 tok after compress — still over window, dead-end 27K tok (~87% reclaimed), under window in one pass
bash scripts/run_tests.sh tests/agent/ -q -k 'tail or oversized' 137 passed, 0 failed
tests/agent/test_protected_tail_pressure_61932.py new-test counterfactual vs main: dead-end reproduced 6 passed, 0 failed
tests/agent/test_context_compressor.py + tool-pruning siblings 207 passed, 0 failed

Credit

Salvaged from #61952 by @giggling-ginger (first submitted Jul 10) — cherry-picked with authorship preserved. Independent approaches to the same bug class, credited: #68377 by @trac3r00 (hard head+tail char cap on tail tool results), #67108 by @Kenmege (tail token budget hard-ceiling override of the message-count floor), #43311 by @josenaicipa (pre-compaction tool-output truncation subsystem for huge tool logs). #61952 was chosen as the base for its correct scoping (targets the exact protected-tail no-op path, data-preserving summaries via _summarize_tool_result, keeps the active ask verbatim) and its earliest submission.

Fixes #61932.

Infographic

protected-tail-dead-end-fix

giggling-ginger and others added 3 commits July 22, 2026 21:02
After multiple in-place compactions, short tool-heavy sessions can leave
nearly every remaining message inside protect_last_n while those messages
are huge completed file/tool outputs. The middle compress window then
makes no material token progress and the turn dies with
"Cannot compress further" (#61932).

Cap the prune message floor at the same bound as tail-cut, and under
pressure demote bulky protected-tail tool bodies (keeping a short recent
floor) so preflight can reclaim headroom without wiping the active ask.
…ressible

Regression test for the exact issue #61932 report: head + an 8-message
protected tail made exclusively of oversized tool pairs.  Pre-fix,
compress_start >= compress_end made compress() a pure no-op and the
retry loop ended in 'Cannot compress further'; post-fix the Phase-1
pressure demotion reclaims the tail in one pass while preserving
tool_call/tool_result pairing.
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 7cf254d

all good!

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/compression Context compression and continuation sessions sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists labels Jul 23, 2026
@teknium1
teknium1 merged commit 18d83b4 into main Jul 23, 2026
40 checks passed
@teknium1
teknium1 deleted the salvage/61952-oversized-tail branch July 23, 2026 14:25
teknium1 added a commit that referenced this pull request Jul 23, 2026
…oactive prune

Follow-ups on top of the cherry-picked #62644 mechanism, porting it to
current main and closing the salvage-review requirements:

- proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS
  when it reclaims a meaningful token batch, measured on the pruned output.
  A committed prune rewrites already-sent history and invalidates the
  provider prompt-cache prefix; this hysteresis gate keeps those breaks
  episodic/amortized (like a compression boundary) instead of firing every
  tool iteration. 0 disables the gate. (Design point credited to the
  #62389 review cycle's prune_minimum_tokens.)
- Standard no-op caller contract: every skip path returns the INPUT list
  object; the loop commits only on 'result is not messages' + non-zero count.
- Loop call is getattr+callable guarded (plugin engines predating the hook,
  SimpleNamespace test doubles) and exception-swallowed at debug level.
- Config parse follows the compression.max_attempts hardened semantics:
  booleans rejected, fractional floats rejected, integral floats/numeric
  strings accepted; negative trigger = disabled.
- cli-config.yaml.example documented (all three keys) and gateway
  _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent.
- Tests: min-reclaim gate both directions, input-object no-op contract,
  no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule),
  default-off zero-behavior-change pin, config parse seam, and behavioral
  loop-wiring tests (consulted/commit/no-op/absent-method/raising).
teknium1 added a commit that referenced this pull request Jul 23, 2026
…oactive prune

Follow-ups on top of the cherry-picked #62644 mechanism, porting it to
current main and closing the salvage-review requirements:

- proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS
  when it reclaims a meaningful token batch, measured on the pruned output.
  A committed prune rewrites already-sent history and invalidates the
  provider prompt-cache prefix; this hysteresis gate keeps those breaks
  episodic/amortized (like a compression boundary) instead of firing every
  tool iteration. 0 disables the gate. (Design point credited to the
  #62389 review cycle's prune_minimum_tokens.)
- Standard no-op caller contract: every skip path returns the INPUT list
  object; the loop commits only on 'result is not messages' + non-zero count.
- Loop call is getattr+callable guarded (plugin engines predating the hook,
  SimpleNamespace test doubles) and exception-swallowed at debug level.
- Config parse follows the compression.max_attempts hardened semantics:
  booleans rejected, fractional floats rejected, integral floats/numeric
  strings accepted; negative trigger = disabled.
- cli-config.yaml.example documented (all three keys) and gateway
  _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent.
- Tests: min-reclaim gate both directions, input-object no-op contract,
  no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule),
  default-off zero-behavior-change pin, config parse seam, and behavioral
  loop-wiring tests (consulted/commit/no-op/absent-method/raising).
teknium1 added a commit that referenced this pull request Jul 23, 2026
…oactive prune

Follow-ups on top of the cherry-picked #62644 mechanism, porting it to
current main and closing the salvage-review requirements:

- proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS
  when it reclaims a meaningful token batch, measured on the pruned output.
  A committed prune rewrites already-sent history and invalidates the
  provider prompt-cache prefix; this hysteresis gate keeps those breaks
  episodic/amortized (like a compression boundary) instead of firing every
  tool iteration. 0 disables the gate. (Design point credited to the
  #62389 review cycle's prune_minimum_tokens.)
- Standard no-op caller contract: every skip path returns the INPUT list
  object; the loop commits only on 'result is not messages' + non-zero count.
- Loop call is getattr+callable guarded (plugin engines predating the hook,
  SimpleNamespace test doubles) and exception-swallowed at debug level.
- Config parse follows the compression.max_attempts hardened semantics:
  booleans rejected, fractional floats rejected, integral floats/numeric
  strings accepted; negative trigger = disabled.
- cli-config.yaml.example documented (all three keys) and gateway
  _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent.
- Tests: min-reclaim gate both directions, input-object no-op contract,
  no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule),
  default-off zero-behavior-change pin, config parse seam, and behavioral
  loop-wiring tests (consulted/commit/no-op/absent-method/raising).
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…oactive prune

Follow-ups on top of the cherry-picked NousResearch#62644 mechanism, porting it to
current main and closing the salvage-review requirements:

- proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS
  when it reclaims a meaningful token batch, measured on the pruned output.
  A committed prune rewrites already-sent history and invalidates the
  provider prompt-cache prefix; this hysteresis gate keeps those breaks
  episodic/amortized (like a compression boundary) instead of firing every
  tool iteration. 0 disables the gate. (Design point credited to the
  NousResearch#62389 review cycle's prune_minimum_tokens.)
- Standard no-op caller contract: every skip path returns the INPUT list
  object; the loop commits only on 'result is not messages' + non-zero count.
- Loop call is getattr+callable guarded (plugin engines predating the hook,
  SimpleNamespace test doubles) and exception-swallowed at debug level.
- Config parse follows the compression.max_attempts hardened semantics:
  booleans rejected, fractional floats rejected, integral floats/numeric
  strings accepted; negative trigger = disabled.
- cli-config.yaml.example documented (all three keys) and gateway
  _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent.
- Tests: min-reclaim gate both directions, input-object no-op contract,
  no-orphan tool_call_id pairing in BOTH directions (NousResearch#69830 pin rule),
  default-off zero-behavior-change pin, config parse seam, and behavioral
  loop-wiring tests (consulted/commit/no-op/absent-method/raising).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Regression]: auto-compression still reaches Cannot compress further when protected recent tail is large (Desktop/openai-codex)

3 participants