mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(compaction): detect and strip merge-into-tail summaries past the delimiter
Follow-up to the END-MARKER reorder: moving the summary prefix after the [PRIOR CONTEXT] wrapper meant _is_context_summary_content (prefix-at-start) no longer recognized a merged-tail summary. That silently broke three consumers — the last-real-user anchor (would pick the merged summary as a real user turn, causing active-task loss), the carry-forward summary find, and the auto-focus skip. _strip_summary_prefix would also carry the wrapper + stale tail content forward as the next summary body. Extract the two delimiter strings into _MERGED_PRIOR_CONTEXT_HEADER / _MERGED_SUMMARY_DELIMITER constants (writer + detector stay in sync), teach _is_context_summary_content and _strip_summary_prefix to look past the delimiter, and add a regression test. Standalone summaries unchanged.
This commit is contained in:
parent
a1a8a967e1
commit
b795a45b8d
3 changed files with 78 additions and 11 deletions
|
|
@ -95,6 +95,15 @@ _SUMMARY_END_MARKER = (
|
|||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
|
||||
# When the summary must be merged into the first tail message (the alternation
|
||||
# corner case where a standalone summary role would collide with both head and
|
||||
# tail), the tail message's own prior content is preserved BEFORE the summary,
|
||||
# wrapped in these delimiters so the model doesn't read it as a fresh message.
|
||||
# The summary prefix therefore lands AFTER _MERGED_SUMMARY_DELIMITER rather than
|
||||
# at the start of the message, so _is_context_summary_content must look past it.
|
||||
_MERGED_PRIOR_CONTEXT_HEADER = "[PRIOR CONTEXT — for reference only; not a new message]"
|
||||
_MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
|
|
@ -2007,6 +2016,13 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
stale directive it carried stays embedded in the body.
|
||||
"""
|
||||
text = (summary or "").strip()
|
||||
# Merge-into-tail summaries wrap prior tail content before the summary
|
||||
# body. Drop everything up to and including the delimiter so only the
|
||||
# real summary body is carried forward on re-compaction — otherwise the
|
||||
# [PRIOR CONTEXT] header and stale tail content leak into the next
|
||||
# summarizer prompt.
|
||||
if _MERGED_SUMMARY_DELIMITER in text:
|
||||
text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].strip()
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix):].lstrip()
|
||||
|
|
@ -2027,6 +2043,13 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
@staticmethod
|
||||
def _is_context_summary_content(content: Any) -> bool:
|
||||
text = _content_text_for_contains(content).lstrip()
|
||||
# Merge-into-tail summaries wrap prior tail content before the summary,
|
||||
# so the handoff prefix lands after _MERGED_SUMMARY_DELIMITER rather than
|
||||
# at the start. Detect the summary in that region too, otherwise callers
|
||||
# (auto-focus skip, carry-forward summary find, last-real-user anchor)
|
||||
# mistake a merged summary message for a real user turn.
|
||||
if _MERGED_SUMMARY_DELIMITER in text:
|
||||
text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip()
|
||||
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
|
|
@ -2898,13 +2921,13 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
# before the summary.
|
||||
old_content = msg.get("content", "")
|
||||
suffix = (
|
||||
"\n\n[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]\n\n"
|
||||
"\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n"
|
||||
+ summary + "\n\n"
|
||||
+ _SUMMARY_END_MARKER
|
||||
)
|
||||
msg["content"] = _append_text_to_content(
|
||||
_append_text_to_content(old_content, suffix, prepend=False),
|
||||
"[PRIOR CONTEXT — for reference only; not a new message]\n",
|
||||
_MERGED_PRIOR_CONTEXT_HEADER + "\n",
|
||||
prepend=True,
|
||||
)
|
||||
# Mark the merged message so frontends can identify it as
|
||||
|
|
|
|||
|
|
@ -357,7 +357,10 @@ class TestCompactionRollupReproduction:
|
|||
in ``web/src/pages/SessionsPage.tsx``)."""
|
||||
|
||||
def test_compress_keeps_visible_reply_text(self, compressor):
|
||||
from agent.context_compressor import SUMMARY_PREFIX
|
||||
from agent.context_compressor import (
|
||||
SUMMARY_PREFIX,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
)
|
||||
c = compressor
|
||||
c.tail_token_budget = 10
|
||||
# ``_generate_summary`` normally wraps the LLM body in
|
||||
|
|
@ -392,10 +395,12 @@ class TestCompactionRollupReproduction:
|
|||
return_value=_mocked,
|
||||
):
|
||||
result = c.compress(messages, current_tokens=90_000)
|
||||
# 1. A summary message exists (compression actually ran).
|
||||
# 1. A summary message exists (compression actually ran). Detect via
|
||||
# the canonical metadata key rather than a content prefix: merge-into-
|
||||
# tail summaries wrap prior content before the summary, so the prefix
|
||||
# is no longer at the start of the message content (#56372).
|
||||
assert any(
|
||||
isinstance(m.get("content"), str)
|
||||
and m["content"].startswith(SUMMARY_PREFIX)
|
||||
m.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
for m in result
|
||||
), "compress() did not insert a summary message"
|
||||
# 2. The visible reply text must survive somewhere — either
|
||||
|
|
@ -419,7 +424,10 @@ class TestCompactionRollupReproduction:
|
|||
as its OWN assistant message — not merged with anything.
|
||||
This is the common case; the merge-into-tail path is the
|
||||
edge case for double-collision."""
|
||||
from agent.context_compressor import SUMMARY_PREFIX
|
||||
from agent.context_compressor import (
|
||||
SUMMARY_PREFIX,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
)
|
||||
c = compressor
|
||||
c.tail_token_budget = 10
|
||||
_mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary"
|
||||
|
|
@ -449,11 +457,11 @@ class TestCompactionRollupReproduction:
|
|||
return_value=_mocked,
|
||||
):
|
||||
result = c.compress(messages, current_tokens=90_000)
|
||||
# Standalone summary present:
|
||||
# Summary present (detect via the canonical metadata key — merge-into-
|
||||
# tail summaries no longer start with SUMMARY_PREFIX after #56372):
|
||||
summary_rows = [
|
||||
m for m in result
|
||||
if isinstance(m.get("content"), str)
|
||||
and m["content"].startswith(SUMMARY_PREFIX)
|
||||
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
]
|
||||
assert len(summary_rows) == 1
|
||||
# Visible reply as its OWN distinct assistant message
|
||||
|
|
@ -463,7 +471,7 @@ class TestCompactionRollupReproduction:
|
|||
if m.get("role") == "assistant"
|
||||
and isinstance(m.get("content"), str)
|
||||
and "THE VISIBLE REPLY THE USER JUST READ" in m["content"]
|
||||
and not m["content"].startswith(SUMMARY_PREFIX)
|
||||
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
]
|
||||
assert len(reply_rows) == 1, (
|
||||
"REGRESSION (#29824): expected exactly one standalone "
|
||||
|
|
|
|||
|
|
@ -2009,6 +2009,42 @@ class TestCompressWithClient:
|
|||
assert text.index("SUMMARY_BODY") < text.index(end)
|
||||
assert text.rstrip().endswith(end)
|
||||
|
||||
def test_merged_tail_summary_still_detected_and_stripped(self):
|
||||
"""Regression for #56372 salvage: the merge-into-tail reorder moves the
|
||||
summary prefix AFTER the [PRIOR CONTEXT] wrapper, so content-prefix
|
||||
detection (_is_context_summary_content) and body extraction
|
||||
(_strip_summary_prefix) must look past the delimiter. Otherwise a merged
|
||||
summary is mistaken for a real user turn (breaking the last-real-user
|
||||
anchor and carry-forward summary find) and the wrapper + stale tail
|
||||
content leaks into the next summarizer prompt.
|
||||
"""
|
||||
from agent.context_compressor import (
|
||||
SUMMARY_PREFIX,
|
||||
_SUMMARY_END_MARKER,
|
||||
_MERGED_PRIOR_CONTEXT_HEADER,
|
||||
_MERGED_SUMMARY_DELIMITER,
|
||||
)
|
||||
|
||||
merged = (
|
||||
_MERGED_PRIOR_CONTEXT_HEADER + "\n"
|
||||
"old tail content here\n\n"
|
||||
+ _MERGED_SUMMARY_DELIMITER + "\n\n"
|
||||
+ SUMMARY_PREFIX + "\nTHE_SUMMARY_BODY\n\n"
|
||||
+ _SUMMARY_END_MARKER
|
||||
)
|
||||
|
||||
# Detected as a summary despite the prefix not being at the start.
|
||||
assert ContextCompressor._is_context_summary_content(merged) is True
|
||||
# Stripping yields only the real summary body — no wrapper, no stale
|
||||
# tail content, no prefix, no end marker.
|
||||
body = ContextCompressor._strip_summary_prefix(merged)
|
||||
assert body == "THE_SUMMARY_BODY"
|
||||
|
||||
# Standalone (non-merged) summaries still work unchanged.
|
||||
standalone = SUMMARY_PREFIX + "\nSTANDALONE_BODY\n\n" + _SUMMARY_END_MARKER
|
||||
assert ContextCompressor._is_context_summary_content(standalone) is True
|
||||
assert ContextCompressor._strip_summary_prefix(standalone) == "STANDALONE_BODY"
|
||||
|
||||
def test_double_collision_user_head_assistant_tail(self):
|
||||
"""Reverse double collision: head ends with 'user', tail starts with 'assistant'.
|
||||
summary='assistant' collides with tail, 'user' collides with head → merge."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue