fix(compressor): skip context-summary markers as last-user tail anchor

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.
This commit is contained in:
Frank Song 2026-07-01 01:06:38 -07:00 committed by Teknium
parent 500c2b1e46
commit ee710db135
2 changed files with 59 additions and 2 deletions

View file

@ -2224,9 +2224,21 @@ This compaction should PRIORITISE preserving all information related to the focu
def _find_last_user_message_idx(
self, messages: List[Dict[str, Any]], head_end: int
) -> int:
"""Return the index of the last user-role message at or after *head_end*, or -1."""
"""Return the index of the last user-role message at or after *head_end*, or -1.
A context-compaction handoff banner can be inserted as a ``role="user"``
message (see the summary-role selection in ``compress``). It is internal
continuity state, not a real user turn, so it must not be picked as the
tail anchor otherwise ``_ensure_last_user_message_in_tail`` protects
the summary and rolls the genuine last user message into the next
compaction, re-triggering the active-task loss the anchor exists to
prevent.
"""
for i in range(len(messages) - 1, head_end - 1, -1):
if messages[i].get("role") == "user":
msg = messages[i]
if msg.get("role") == "user" and not self._is_context_summary_content(
msg.get("content")
):
return i
return -1

View file

@ -477,6 +477,51 @@ class TestCompactionRollupReproduction:
# ---------------------------------------------------------------------------
class TestFindLastUserMessageIdxSkipsSummaryMarker:
"""A context-compaction handoff banner is inserted with ``role="user"``
when the head ends in an assistant/tool message (see the summary-role
selection in ``compress``). ``_find_last_user_message_idx`` must NOT treat
that banner as the latest user turn otherwise, on a resumed or
multi-compaction session, ``_ensure_last_user_message_in_tail`` anchors the
tail to the summary and rolls the genuine last user message into the next
compaction, re-triggering the active-task loss the anchor exists to prevent.
(Salvaged from #36626 / issue #36624.)
"""
def test_skips_user_role_context_summary_marker(self, compressor):
from agent.context_compressor import SUMMARY_PREFIX
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "REAL current task"},
{"role": "assistant", "content": "working on it"},
# A handoff summary re-inserted as a user-role message after resume.
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nold"},
{"role": "assistant", "content": "continuing from the real task"},
]
# Latest *real* user message is index 1, not the summary at index 3.
assert compressor._find_last_user_message_idx(messages, head_end=1) == 1
def test_returns_real_user_when_no_summary_present(self, compressor):
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": "second"},
]
assert compressor._find_last_user_message_idx(messages, head_end=1) == 3
def test_all_user_messages_are_summaries_returns_minus_one(self, compressor):
from agent.context_compressor import SUMMARY_PREFIX
messages = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\nhandoff"},
]
assert compressor._find_last_user_message_idx(messages, head_end=1) == -1
class TestSourceGuardrail:
@pytest.fixture
def source(self) -> str: