Skip to content

fix(compressor): prevent orphan user turn after compaction (turn-pair preservation) - #22523

Closed
H2KFORGIVEN wants to merge 1 commit into
NousResearch:mainfrom
H2KFORGIVEN:fix/compressor-orphan-turn-pair-preservation
Closed

fix(compressor): prevent orphan user turn after compaction (turn-pair preservation)#22523
H2KFORGIVEN wants to merge 1 commit into
NousResearch:mainfrom
H2KFORGIVEN:fix/compressor-orphan-turn-pair-preservation

Conversation

@H2KFORGIVEN

Copy link
Copy Markdown
Contributor

Problem

When context compression fires, _ensure_last_user_message_in_tail anchors
cut_idx to the last user message so the active task isn't lost. However
_find_tail_cut_by_tokens then applies max(cut_idx, head_end + 1) before
returning — this advances the cut past the user message when
last_user_idx <= head_end, placing the user turn in the compressed region
without 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 user
turn after the summary, treats it as a new instruction, and re-executes the
already-completed task
.

Real observed symptoms:

  • Agent turned lights off twice (once in session, once after compaction in a new session)
  • Agent re-sent a message that was already sent
  • Agent repeated a file-write that was already done

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 undo
that guarantee.

Fix

Apply the Causal Coupling principle from agent-scaffolding literature:

A compaction boundary must never split a (user → assistant → tool_results)
turn-pair. Both halves must land on the same side of the cut.

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 it
never crosses into a different turn.

2. Causal Coupling guard in _ensure_last_user_message_in_tail

After computing adjusted = max(last_user_idx, head_end + 1), checks whether
the cut would land between the user and its reply. When a split is detected,
pushes cut_idx to pair_end so the whole pair lands in the compressed
region and is correctly summarised as completed.

Tests

All 81 existing compressor tests pass unchanged.

New class TestTurnPairPreservation (8 tests):

  • 4 unit tests for _find_turn_pair_end (user-only, with reply, with tools, multi-turn)
  • 4 tests for _ensure_last_user_message_in_tail (already-in-tail, pullback, orphan-prevention, end-to-end no-orphan invariant)
89 passed in 4.08s

Impact

  • No behaviour change when the user message is already safely in the tail
  • No performance impact_find_turn_pair_end is O(k) where k is the number of tool results in the turn (typically 1–5)
  • Fixes silent repeated-work bugs that are difficult to reproduce deterministically

…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).
@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 P1 High — major feature broken, no workaround labels May 11, 2026
@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jun 29, 2026
@teknium1

teknium1 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Merged via PR #56121: #56121

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 teknium1 closed this Jul 1, 2026
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.
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 P1 High — major feature broken, no workaround sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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.

3 participants