Skip to content

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

Open
hashbender wants to merge 1 commit into
mainfrom
mirror/pr-56121
Open

fix(compressor): prevent orphan user turn after compaction (turn-pair preservation)#200
hashbender wants to merge 1 commit into
mainfrom
mirror/pr-56121

Conversation

@hashbender

Copy link
Copy Markdown
Owner

Summary

Compaction no longer orphans the last user turn. When the last user message sits exactly at head_end (the first compressible index), the completed turn-pair is summarised as a unit instead of being split — so the summariser stops recording a finished ask as "pending" and the next session stops re-executing already-done work.

Root cause: _ensure_last_user_message_in_tail's final max(last_user_idx, head_end + 1) clamp returns head_end + 1 when last_user_idx == head_end, pushing the user past the cut into the compressed region without its assistant reply.

Salvages NousResearch#22523 by @H2KFORGIVEN — reimplemented on current main (the original branch was ~6000 commits stale; the code moved from line ~1190 to ~2332).

Changes

  • agent/context_compressor.py: add _find_turn_pair_end() and a Causal Coupling guard in _ensure_last_user_message_in_tail — when the clamp would orphan the user, push the cut forward to pair_end so (user → assistant → tool results) lands together in the summary.
  • tests/agent/test_context_compressor.py: 8 new tests in TestTurnPairPreservation (4 for _find_turn_pair_end, 4 for the anchor incl. an end-to-end no-orphan invariant).
  • scripts/release.py: AUTHOR_MAP entry for @H2KFORGIVEN.

Validation

E2E-verified on current main: the orphan reproduces exactly as described (returns head_end+1, user pushed out of the tail); the fix keeps the completed pair together and the tail starts on an assistant reply. NousResearch#10896's mid-conversation pullback is unchanged (guard only fires when last_user_idx == head_end).

Before After
last user at head_end, cut>head_end returns head_end+1 → user orphaned in summary returns pair_end → user+reply summarised together
tail start after compaction dangling user ask assistant reply
compressor tests 125 pass 133 pass (8 new)

Infographic

Turn-Pair Preservation


Mirror-of: NousResearch#56121
NousResearch#56121

@tenki-reviewer

tenki-reviewer Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Complete

Files Reviewed: 3
Findings: 1

By Severity:

  • 🟡 Medium: 1

A bug in the new _find_turn_pair_end helper truncates multi-step tool-call turns during context compression, potentially leaking mid-turn messages without their originating user context and risking confusion or re-execution on the next session.

Files Reviewed (3 files)
agent/context_compressor.py
scripts/release.py
tests/agent/test_context_compressor.py

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Risk: 🟠 High (62/100) — 1 medium finding · 194 LOC across 3 files


Summary

PR #200 introduces a Causal Coupling guard in agent/context_compressor.py that ensures the compaction boundary respects complete user→assistant→tool turn-pairs. However, the new helper _find_turn_pair_end (line 2406–2431) only consumes the first assistant message and its immediate tool results, stopping at the next non-tool role.

Issue

In real Hermes agent loops, a single user turn routinely triggers multiple rounds of model calls: user → assistant(tool_calls) → tool → assistant(tool_calls) → tool → … → assistant(text). The current implementation returns after the first assistant→tool cycle, splitting a multi-step turn across the compaction boundary. This causes:

  1. The compression summary sees only a partial turn (user asked + first tool result, no completion)
  2. Remaining mid-turn messages land in the tail without user context
  3. The model on next session may see orphaned assistant/tool messages with no preceding user instruction

Fix

Rewrite _find_turn_pair_end to walk forward until the next user-role message (the natural turn boundary), capturing all assistant and tool messages belonging to the same user turn. A test case for multi-step tool-call turns should be added to tests/agent/test_context_compressor.py.

Comment on lines +2406 to +2431
def _find_turn_pair_end(
self,
messages: List[Dict[str, Any]],
user_idx: int,
) -> int:
"""Return the index *after* the complete turn-pair starting at *user_idx*.

A turn-pair is: ``user`` -> ``assistant`` [-> zero-or-more ``tool``
results]. Returns the index of the first message that does *not*
belong to the pair, i.e. the natural cut point that keeps the pair
intact on one side of the boundary.

If *user_idx* is the last message (no assistant reply yet), returns
``user_idx + 1`` so the user message itself is minimally covered.
"""
n = len(messages)
idx = user_idx + 1
if idx >= n:
return idx # user is the very last message — no reply yet
if messages[idx].get("role") != "assistant":
return idx # no assistant reply immediately following
idx += 1
# Include any tool results that belong to this assistant turn.
while idx < n and messages[idx].get("role") == "tool":
idx += 1
return idx

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 _find_turn_pair_end truncates multi-step tool-call turns, leaking mid-turn messages into the tail without user context (bug)

The new _find_turn_pair_end helper (agent/context_compressor.py:2406-2431) is called by the Causal Coupling guard in _ensure_last_user_message_in_tail (line 2394) to push the compaction boundary forward past the complete user→assistant→tool turn-pair when the user sits at the head boundary and cannot be pulled into the tail. However, the function only walks forward past the FIRST assistant message and its immediate consecutive tool results, then stops at the next non-tool role. This misses subsequent assistant(tool_calls)→tool(result) cycles and the assistant's final text reply, all of which are part of the same user turn. In real Hermes agent loops, a single user message commonly triggers multiple rounds of tool calls (the agent loops: call model → get tool_calls → execute → add tool results → call model again → … → get text reply). When the Causal Coupling guard fires with such a multi-step turn, the truncated pair_end causes: (1) only the first segment enters the compression summary (so the summarizer sees user asked + assistant started work + first tool result, but no completion); (2) the remaining mid-turn messages (subsequent assistant tool_calls + results + final text reply) land in the tail without their originating user context. The model on the next session may see mid-turn messages with no user instruction, causing confusion or re-execution of already-completed work.

💡 Suggestion: Rewrite _find_turn_pair_end to walk forward until the next user-role message (or end of list), which is the natural boundary between turns. This captures the complete multi-step turn: all assistant and tool messages belonging to the same user message stay together on one side of the compaction cut.

📋 Prompt for AI Agents

In agent/context_compressor.py, replace the body of _find_turn_pair_end (lines 2421-2431) with a loop that walks forward past ALL non-user messages: idx = user_idx + 1; while idx < n and messages[idx].get('role') != 'user': idx += 1; return idx. Update the docstring to document multi-step tool-call handling: change 'A turn-pair is: user -> assistant [-> zero-or-more tool results]' to describe the full agent-loop pattern including multiple assistant→tool cycles. Add a test case in tests/agent/test_context_compressor.py (TestTurnPairPreservation class) for a multi-step turn: user → assistant(tool_calls) → tool → assistant(tool_calls) → tool → assistant(text) → next_user, verifying pair_end returns the index of next_user.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant