Skip to content

fix(compaction): let tail token budget override the message-count floor on pathological tails - #67108

Closed
Kenmege wants to merge 1 commit into
NousResearch:mainfrom
Kenmege:fix/tail-token-cap-pathological
Closed

fix(compaction): let tail token budget override the message-count floor on pathological tails#67108
Kenmege wants to merge 1 commit into
NousResearch:mainfrom
Kenmege:fix/tail-token-cap-pathological

Conversation

@Kenmege

@Kenmege Kenmege commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a compression-exhaustion failure mode: when the protected recent tail holds a few enormous messages (e.g. giant tool results from file reads), the message-count floor (protect_last_n, capped by _MAX_TAIL_MESSAGE_FLOOR) forces all of them to be kept verbatim regardless of their token mass. The tail becomes incompressible, every compression pass is a sub-5% no-op, the attempt budget burns down, and the turn dies with max compression attempts reached — even when the middle of the transcript is already fully summarized.

Observed in production on a long-running coding session (17 h, 1000+ messages, tool-heavy):

Preflight compression failed after 6 attempts: est_request~215,205 tok vs
threshold~204,000; split messages~204,844 + tools/overhead~10,361

The transcript middle was already a compaction summary; ~all of the token mass sat in 8 count-floor-protected giant tool results that no pass was allowed to touch.

The fix: in the backward tail walk of _find_tail_cut_by_tokens, add hard_ceiling = token_budget * 3. Once the tail is already viable — it holds the absolute minimum of 3 messages and the most recent user message — the count floor yields to the token ceiling if the next message is individually oversized (> soft_ceiling) and would push the accumulated tail past the hard ceiling. The oversized messages then fall into the summarized region instead of being pinned.

Why this approach: it changes nothing on healthy transcripts. The extra msg_tokens > soft_ceiling gate means a long run of normal-sized turns under a tiny budget still honors the message-count floor exactly as today (the #9413 behavior is regression-tested). Only the genuine few-but-huge pathology engages the override, and all existing invariants hold: tool_call/result groups are never split (_align_boundary_backward still runs), the last user/assistant messages are still guaranteed in the tail (_ensure_last_*_message_in_tail run afterwards and only grow), and the tail never shrinks below 3 messages.

This also addresses the "Auto-Forget when compression targets fail" ask in #21916, which was closed with sweeper:implemented-on-main — the token-budget tail protection on main is real, but it yields to the count floor, so the few-but-huge case documented there (37,299 → 34,080 tok, still far above target, session reset) still reproduces. This PR closes that gap.

Related Issue

Fixes #21916

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • agent/context_compressor.py_find_tail_cut_by_tokens: added hard_ceiling (3× token_budget), a triple-gated hard-ceiling break in the backward walk (next message individually > soft_ceiling + accumulated > hard_ceiling + tail already ≥ 3 messages with the last user message inside), and a _tail_floor clamp so the fallback min() cannot silently re-expand the tail back to the count floor after the break fires. +39 lines, no behavior change outside the pathological case.
  • tests/agent/test_compression_tail_token_cap.py — new: 5 tests covering the pathological shrink (below count floor, never below 3), tool-group integrity across the new cut, last-user-message retention, healthy-transcript no-op (count floor still honored under a tiny budget), and the pre-fix behavioral delta (the giant messages that the unfixed floor pins are excluded once the fix is active).

How to Test

  1. python -m pytest tests/agent/test_compression_tail_token_cap.py -v — 5 passed.
  2. python -m pytest tests/agent -k "tail or compress" -q — 98 passed, 1 skipped (pre-existing skip), 0 failures.
  3. Repro of the bug class without the fix: revert the agent/context_compressor.py hunk and re-run step 1 — the pathological-tail tests fail because the count floor pins all giant tail messages and the cut never moves past them.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run the full suite via scripts/run_tests.sh (the canonical CI-matching runner). Honest caveat: 20 environment-dependent failures on my macOS dev box (man-page binary tests, tempdir-path assumptions) — reproduced byte-for-byte identical at the merge-base without this change (the diff is 2 files, disjoint from every failing suite), so they are pre-existing local-environment issues, not regressions. Everything related to this change is green: the new test file 5/5, tests/agent -k "tail or compress" 98 passed / 1 pre-existing skip. Deferring to CI as the clean-environment arbiter.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 26.5.2 (Apple Silicon)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A (behavior documented in code comments at the change site)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no new config keys; hard_ceiling derives from the existing token_budget)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure-Python arithmetic/slicing, no platform surface
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

Production failure signature this fixes (long-running tool-heavy session, transcript middle already summarized, tail pinned by the count floor):

ERROR agent.conversation_loop: Preflight compression failed after 6 attempts:
est_request~215,205 tok vs threshold~204,000;
split messages~204,844 + tools/overhead~10,361

…or on pathological tails

The protected recent tail is bounded by a message-count floor
(`protect_last_n`, capped at `_MAX_TAIL_MESSAGE_FLOOR`) so a short run of
recent turns survives compaction verbatim. That floor is applied regardless
of the tail's token mass. When the tail holds a few *enormous* messages —
e.g. giant tool results from a file read — the floor forces all of them to be
kept, producing an incompressible tail that can pin the request over the
model's context window indefinitely.

Failure signature: compression exhaustion. The compressor runs its full
attempt budget making sub-5% no-op passes (the protected tail is simply
incompressible), then aborts with "max compression attempts reached" even
though the estimate still fits the window — a few huge tool results are
pinning the count-floored tail.

Fix: add `hard_ceiling = token_budget * 3`. In the backward tail walk, once
the tail is already viable — it holds the absolute minimum of 3 messages and
the most recent user message is captured — the token ceiling overrides the
count floor and stops the tail from growing further. The override is gated on
the next message being individually oversized (larger than the soft ceiling),
so it only engages on the genuine few-but-huge pathology and never on a long
run of small turns under a tiny budget (which must still honour the count
floor, NousResearch#9413). The fallback floor drops from `min_tail` to 3 when the override
fires, so `min()` cannot silently re-expand the tail back to the count floor.
The existing `_ensure_last_user_message_in_tail` /
`_ensure_last_assistant_message_in_tail` anchors still run afterwards and can
only grow the tail, so the most recent user/assistant turn is never lost, and
tool_call/result groups are still never split.

Adds estimator-agnostic regression tests: message sizes are measured with the
compressor's own token estimator and the tail budget is derived from those
measurements, so the tests hold regardless of the estimator's chars-per-token
calibration. They cover: the tail shrinking below the count floor but never
below 3, the giant message being excluded from the tail where the unfixed
floor would have kept it, the most recent user message staying in the tail,
tool groups never being split, and a normal small-message tail still honouring
the count floor.

Fixes NousResearch#21916

Co-Authored-By: Claude <noreply@anthropic.com>
@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 P2 Medium — degraded but workaround exists needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 18, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #61952: both address protected-tail compaction exhaustion, but this changes the tail-cut floor while #61952 pressure-demotes oversized tool bodies. Maintainer choice needed.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating a real count-floor exhaustion path. Current main still retains an oversized message before min_tail is met (agent/context_compressor.py:3045) and re-applies the floor at agent/context_compressor.py:3079-3081.

Problems

  • The new hard-ceiling break does not work for the stated giant-tool-result case. If it breaks immediately after a role="tool" message, the existing _align_boundary_backward() walks back over that tool result and its parent assistant tool-call (agent/context_compressor.py:2765-2772), placing the boundary before the group. The oversized tool body is therefore retained in the tail.
  • The new test fixture covers a huge non-tool assistant message, so it cannot expose that alignment path.

Suggested changes

  • Make the ceiling decision group-aware and cut before a complete oversized assistant/tool-result group.
  • Add an end-to-end compress() regression with a giant paired tool result immediately before the viable tail; verify the group is summarized while the user/assistant anchors and tool-pair validity hold.

This is an automated hermes-sweeper review.

msg_tokens > soft_ceiling
and accumulated + msg_tokens > hard_ceiling
and (n - cut_idx) >= 3
and last_user_idx >= 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This break can leave cut_idx immediately after a giant role="tool" result, but the unchanged _align_boundary_backward() then walks back over that result and its parent assistant.tool_calls group (agent/context_compressor.py:2765-2772). The group, including the giant body this PR targets, is re-added to the tail. Make this decision group-aware and add a paired-tool-result regression.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 19, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #69830 (commit 18d83b4). The protected-tail dead-end (#61932) was fixed via #69830 (salvage of #61952). Your tail-cut hard-ceiling addressed the budget floor but not the prune-path no-op that caused the dead-end; credited as an independent approach in the merged body. Thanks!

@teknium1 teknium1 closed this Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades 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.

[Feature]: Compression logic deadlock: Suggest adding a sliding window "Auto-Forget" mechanism when compression targets fail

3 participants