fix(compressor): prevent orphan user turn after compaction (turn-pair preservation) - #22523
Closed
H2KFORGIVEN wants to merge 1 commit into
Closed
Conversation
…air preservation When _ensure_last_user_message_in_tail pulls cut_idx back to include the last user message, the call-site in _find_tail_cut_by_tokens still applies max(cut_idx, head_end + 1). When the user message sits at or near head_end this max() pushes the cut *past* the user, leaving it in the compressed region without its assistant reply. The LLM summariser then sees an unanswered request and marks it as 'Pending User Asks'. The next session's model sees the dangling user turn after the summary, treats it as a new instruction, and re-executes the already-completed task (e.g. turning lights off again, deleting a file twice, re-sending a message). Fix: apply the Causal Coupling principle — a compaction boundary must never split a (user → assistant [→ tool results]) turn-pair. Both halves must land on the same side of the cut. Implement this via two additions: 1. _find_turn_pair_end(messages, user_idx): returns the index after the complete turn-pair (user + assistant + any tool results), so the caller can locate the natural cut point that keeps the pair intact. 2. Causal Coupling guard in _ensure_last_user_message_in_tail: detects when the adjusted cut would land between the user and its reply, then pushes cut_idx to pair_end so the whole pair is summarised together and correctly marked as completed in the summary. The existing 81 compressor tests all pass unchanged. Eight new tests in TestTurnPairPreservation cover _find_turn_pair_end (4 unit tests) and _ensure_last_user_message_in_tail (4 tests including an end-to-end no-orphan invariant).
teknium1
added a commit
that referenced
this pull request
Jul 1, 2026
Contributor
|
Your fix was reimplemented on current main (the branch was ~6000 commits stale and the code had moved from line 1190 to 2332), with your authorship preserved in git history on commit fc2fac7. The Causal Coupling guard and _find_turn_pair_end landed as you designed them, plus your 8 tests. Verified E2E: the orphan reproduces exactly as you described, and the fix keeps the completed turn-pair together. Thanks for the well-analyzed fix! |
teknium1
pushed a commit
that referenced
this pull request
Jul 1, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (#10896/#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from #36626 by Frank Song (issue #36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (#39170), the no-op compression counting (#40803), and the existing 413/disabled terminal-error paths.
waefrebeorn
pushed a commit
to waefrebeorn/slermes
that referenced
this pull request
Jul 2, 2026
waefrebeorn
pushed a commit
to waefrebeorn/slermes
that referenced
this pull request
Jul 2, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (NousResearch#10896/NousResearch#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from NousResearch#36626 by Frank Song (issue NousResearch#36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (NousResearch#39170), the no-op compression counting (NousResearch#40803), and the existing 413/disabled terminal-error paths.
Jasper6439
pushed a commit
to Jasper6439/hermes-agent
that referenced
this pull request
Jul 5, 2026
Jasper6439
pushed a commit
to Jasper6439/hermes-agent
that referenced
this pull request
Jul 5, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (NousResearch#10896/NousResearch#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from NousResearch#36626 by Frank Song (issue NousResearch#36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (NousResearch#39170), the no-op compression counting (NousResearch#40803), and the existing 413/disabled terminal-error paths.
habarmc1223-sudo
pushed a commit
to habarmc1223-sudo/hermes-agent-fluxmem
that referenced
this pull request
Jul 8, 2026
habarmc1223-sudo
pushed a commit
to habarmc1223-sudo/hermes-agent-fluxmem
that referenced
this pull request
Jul 8, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (NousResearch#10896/NousResearch#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from NousResearch#36626 by Frank Song (issue NousResearch#36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (NousResearch#39170), the no-op compression counting (NousResearch#40803), and the existing 413/disabled terminal-error paths.
lenardhuebner88-rgb
pushed a commit
to lenardhuebner88-rgb/hermes-agent
that referenced
this pull request
Jul 10, 2026
… cut Two related defects in _find_tail_cut_by_tokens' anchor chain, both manifesting as the protected tail ballooning to nearly the whole transcript, compression degrading to a near-no-op, and the reactive overflow recovery re-sending an oversized prompt until the turn dies with 'max compression attempts (3) reached': 1. The NousResearch#22523 causal-coupling branch in _ensure_last_user_message_in_tail returned bare max(pair_end, head_end+1), silently discarding the deep budget cut (reproduced: cut 290 -> 4 on a 302-message transcript; the shape is common from the second compaction on, when protect_first_n decays to 0 and the only real user message sits right after the system prompt). Now returns max(cut_idx, pair_end, head_end+1) — pair-unit semantics preserved, budget cut kept. 2. The assistant anchor (NousResearch#29824) could pull the cut back INSIDE that head turn-pair when the pair's own reply is the last content-bearing assistant, splitting the pair the user anchor deliberately summarises as a unit. New _head_pair_floor clamps the final cut to the pair end. Ledgered residual (design decision, not changed): with an ANCIENT only-content-bearing reply the assistant anchor may still balloon the tail — the pinned NousResearch#29824 tests explicitly privilege verbatim reply retention over the token budget. Cross-family review: Codex PASS (round 2 + amendment). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
santhreal
pushed a commit
to santhreal/hermes-agent
that referenced
this pull request
Jul 13, 2026
santhreal
pushed a commit
to santhreal/hermes-agent
that referenced
this pull request
Jul 13, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (NousResearch#10896/NousResearch#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from NousResearch#36626 by Frank Song (issue NousResearch#36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (NousResearch#39170), the no-op compression counting (NousResearch#40803), and the existing 413/disabled terminal-error paths.
Gravezzz
pushed a commit
to Gravezzz/hermes-agent
that referenced
this pull request
Jul 21, 2026
Gravezzz
pushed a commit
to Gravezzz/hermes-agent
that referenced
this pull request
Jul 21, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (NousResearch#10896/NousResearch#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from NousResearch#36626 by Frank Song (issue NousResearch#36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (NousResearch#39170), the no-op compression counting (NousResearch#40803), and the existing 413/disabled terminal-error paths.
waym0reom3ga
pushed a commit
to waym0reom3ga/autolycus-agent
that referenced
this pull request
Jul 23, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (NousResearch#10896/NousResearch#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from NousResearch#36626 by Frank Song (issue NousResearch#36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (NousResearch#39170), the no-op compression counting (NousResearch#40803), and the existing 413/disabled terminal-error paths.
akay64
pushed a commit
to akay64/hermes-agent
that referenced
this pull request
Aug 2, 2026
protect_first_n is retired as a behavioral mechanism: the constructor normalizes the attribute to 0 (parameter retained for call compatibility), the head is the system prompt only, and downstream preflight consumers (turn_context, context_switch_guard) compute protect_last_n + 1. The decay machinery (_effective_protect_first_n) is deleted — nothing protects, so nothing decays; the continuity gap and causal-coupling chain break are impossible by construction. The last real user message is anchored unconditionally: the NousResearch#22523 turn-pair push and _find_turn_pair_end are removed, so the cut never passes the user message. A user message at the head boundary yields an empty middle, which is now a legitimate no-op — the force-cut is removed and the final clamp relaxed to head_end; the NousResearch#40803 raw-budget re-walk is kept, and the anti-thrashing counter owns the loop-guard role. Base64 payloads never enter the summarizer input: _strip_base64_blobs() collapses data:image/...;base64, payloads at any length and bare base64-alphabet runs >= 200 chars, URL-span-aware (http(s) spans preserved), applied to all roles and tool-call arguments in both serialization modes. Test suite reconciled to the new semantics: ~60 tests adapted (boundary pinning, system-only-head scenarios) plus new regression classes for the single-trigger pruner-only no-op, two-strike anti-thrash gate, and the URL-safe base64 rules. One pre-existing failure fixed along the way (stale full_fidelity_turns fake signature).
leewenjie
pushed a commit
to leewenjie/hermes-agent
that referenced
this pull request
Aug 7, 2026
leewenjie
pushed a commit
to leewenjie/hermes-agent
that referenced
this pull request
Aug 7, 2026
A context-compaction handoff banner is inserted with role="user" when the protected head ends in an assistant/tool message. On a resumed or multi-compaction session, _find_last_user_message_idx would return that banner as the latest user turn, so _ensure_last_user_message_in_tail anchored the tail to the summary and rolled the genuine last user message into the next compaction — the exact active-task loss the anchor exists to prevent (NousResearch#10896/NousResearch#22523). Reuse the existing _is_context_summary_content helper to skip summary banners when locating the last real user message. Salvaged from NousResearch#36626 by Frank Song (issue NousResearch#36624). The PR's other two changes (demoting completed tool results inside the protected tail; a preflight compression_exhausted result) are superseded on current main by the min_tail floor (NousResearch#39170), the no-op compression counting (NousResearch#40803), and the existing 413/disabled terminal-error paths.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When context compression fires,
_ensure_last_user_message_in_tailanchorscut_idxto the last user message so the active task isn't lost. However_find_tail_cut_by_tokensthen appliesmax(cut_idx, head_end + 1)beforereturning — this advances the cut past the user message when
last_user_idx <= head_end, placing the user turn in the compressed regionwithout its assistant reply.
The LLM summariser sees an unanswered request and writes it into
## Pending User Asks. The next session's model then finds a dangling userturn after the summary, treats it as a new instruction, and re-executes the
already-completed task.
Real observed symptoms:
This is a latent second form of the original #10896 bug: the first fix kept
the user message out of the summary, but the
max()call could silently undothat guarantee.
Fix
Apply the Causal Coupling principle from agent-scaffolding literature:
Two additions:
1.
_find_turn_pair_end(messages, user_idx)Returns the index after the complete turn-pair that starts at
user_idx:user → assistant [→ tool results]. Stops at the next user message so itnever crosses into a different turn.
2. Causal Coupling guard in
_ensure_last_user_message_in_tailAfter computing
adjusted = max(last_user_idx, head_end + 1), checks whetherthe cut would land between the user and its reply. When a split is detected,
pushes
cut_idxtopair_endso the whole pair lands in the compressedregion and is correctly summarised as completed.
Tests
All 81 existing compressor tests pass unchanged.
New class
TestTurnPairPreservation(8 tests):_find_turn_pair_end(user-only, with reply, with tools, multi-turn)_ensure_last_user_message_in_tail(already-in-tail, pullback, orphan-prevention, end-to-end no-orphan invariant)Impact
_find_turn_pair_endis O(k) where k is the number of tool results in the turn (typically 1–5)