mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(agent): do not destroy compacted history when a session resumes
The rolling summary lives only in memory. A resumed session starts with an empty one while the marker carrying every previously absorbed exchange is still in the transcript. The first pass after a resume therefore built a marker from a single exchange and superseded the marker holding the entire history -- silently discarding everything micro-compaction had accumulated. This was introduced by the supersede fix. Before it, markers piled up wastefully, but nothing was ever lost. Two changes, so a single failure cannot lose data: Rehydrate. When the cursor is recovered by scanning the transcript -- the resume path -- also recover the rolling summary from that marker, so the next pass merges into the existing history instead of replacing it. Extraction uses rfind for the heading because SUMMARY_PREFIX references the heading text itself, so the first occurrence is inside the preamble. Gate superseding. Earlier markers are dropped only when this pass's summary is demonstrably cumulative, i.e. the rolling summary was non-empty going in. If rehydration ever fails, the pass keeps both markers: wasteful, but the history survives. Tests cover the resume path, the failed-rehydration fallback, and the round trip of a summary through a marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e8237050fc
commit
60781a0cc8
2 changed files with 105 additions and 8 deletions
|
|
@ -5034,6 +5034,19 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
last_summary_idx = idx
|
||||
if last_summary_idx >= head_end:
|
||||
cursor = last_summary_idx + 1
|
||||
# Resumed session: in-memory state is gone but the marker survives.
|
||||
# Carry its text forward so the next pass merges into the existing
|
||||
# history instead of replacing it with a single-exchange summary.
|
||||
if not self._micro_compact_rolling_summary.strip():
|
||||
recovered = self._rolling_summary_from_marker(
|
||||
messages[last_summary_idx].get("content")
|
||||
)
|
||||
if recovered:
|
||||
self._micro_compact_rolling_summary = recovered
|
||||
logger.info(
|
||||
"Micro-compaction: recovered rolling summary from "
|
||||
"transcript (%d chars)", len(recovered),
|
||||
)
|
||||
else:
|
||||
cursor = head_end
|
||||
self._micro_compact_cursor = cursor
|
||||
|
|
@ -5363,6 +5376,10 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
)
|
||||
return result
|
||||
|
||||
# Whether this pass's summary will be cumulative — i.e. whether it
|
||||
# subsumes any earlier marker. Captured before summarizing.
|
||||
_cumulative = bool(self._micro_compact_rolling_summary.strip())
|
||||
|
||||
# Micro-summarize one exchange
|
||||
exchange_text = self._serialize_one_exchange(messages, exchange_start, exchange_end)
|
||||
_exchange_tokens = estimate_tokens_rough(exchange_text)
|
||||
|
|
@ -5408,7 +5425,9 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
self._micro_compact_consecutive_failures = 0
|
||||
self._micro_compact_last_failure_cursor = -1
|
||||
|
||||
result = self._splice_micro_compact_result(messages, exchange_start, exchange_end)
|
||||
result = self._splice_micro_compact_result(
|
||||
messages, exchange_start, exchange_end, supersede=_cumulative,
|
||||
)
|
||||
self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1)
|
||||
self._sync_micro_compact_to_db(result)
|
||||
self._emit_micro_compaction_telemetry(
|
||||
|
|
@ -5422,6 +5441,29 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _rolling_summary_from_marker(content: Any) -> str:
|
||||
"""Recover the rolling-summary text from a summary marker's content.
|
||||
|
||||
The rolling summary lives in memory, but a resumed session starts with
|
||||
an empty one while the marker holding every previous exchange is still
|
||||
in the transcript. Without rehydrating from it, the first post-resume
|
||||
pass would build a marker from nothing and supersede the one carrying
|
||||
the whole history.
|
||||
"""
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
return ""
|
||||
body = content
|
||||
# rfind, not find: SUMMARY_PREFIX itself references the heading text,
|
||||
# so the first occurrence is inside the preamble, not the real heading.
|
||||
idx = body.rfind(HISTORICAL_TASK_HEADING)
|
||||
if idx != -1:
|
||||
body = body[idx + len(HISTORICAL_TASK_HEADING):]
|
||||
end = body.find(_SUMMARY_END_MARKER)
|
||||
if end != -1:
|
||||
body = body[:end]
|
||||
return body.strip()
|
||||
|
||||
def _cursor_after_splice(
|
||||
self,
|
||||
result: List[Dict[str, Any]],
|
||||
|
|
@ -5550,6 +5592,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
messages: List[Dict[str, Any]],
|
||||
splice_start: int,
|
||||
splice_end: int,
|
||||
supersede: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Replace *messages[splice_start:splice_end]* with a summary marker.
|
||||
|
||||
|
|
@ -5584,13 +5627,19 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
# its own prefix/heading/end-marker scaffolding — so the transcript
|
||||
# grows with every turn instead of shrinking, which defeats the point.
|
||||
# Keep only the newest marker.
|
||||
marker_idxs = [
|
||||
i for i, m in enumerate(result)
|
||||
if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
]
|
||||
if len(marker_idxs) > 1:
|
||||
superseded = set(marker_idxs[:-1])
|
||||
result = [m for i, m in enumerate(result) if i not in superseded]
|
||||
# Only drop earlier markers when this one demonstrably contains them:
|
||||
# the rolling summary must have been non-empty going into this pass.
|
||||
# A pass that started from nothing (a resume that could not rehydrate)
|
||||
# produces a marker covering one exchange, and dropping the previous
|
||||
# marker would throw away the entire compacted history.
|
||||
if supersede:
|
||||
marker_idxs = [
|
||||
i for i, m in enumerate(result)
|
||||
if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
]
|
||||
if len(marker_idxs) > 1:
|
||||
superseded = set(marker_idxs[:-1])
|
||||
result = [m for i, m in enumerate(result) if i not in superseded]
|
||||
|
||||
_strip_persistence_markers(result)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -164,6 +164,54 @@ class TestMicroCompaction:
|
|||
"cursor must sit just past the marker in the spliced list"
|
||||
)
|
||||
|
||||
def test_resume_does_not_destroy_the_accumulated_summary(self):
|
||||
"""A resumed session must not throw away compacted history.
|
||||
|
||||
The rolling summary lives in memory; a resumed process starts with an
|
||||
empty one while the marker holding every previous exchange is still in
|
||||
the transcript. Superseding on that first pass would replace the whole
|
||||
history with a summary of one exchange.
|
||||
"""
|
||||
msgs = _conversation(exchanges=10)
|
||||
first = _compressor(summary="IMPORTANT HISTORY: decisions and paths")
|
||||
for _ in range(3):
|
||||
msgs = first._micro_compact(msgs)
|
||||
assert any("IMPORTANT HISTORY" in m["content"] for m in _summary_markers(msgs))
|
||||
|
||||
# Fresh compressor over the same transcript = resume.
|
||||
resumed = _compressor(summary="MERGED: history plus newest exchange")
|
||||
assert resumed._micro_compact_rolling_summary == ""
|
||||
result = resumed._micro_compact(msgs)
|
||||
|
||||
markers = _summary_markers(result)
|
||||
assert len(markers) == 1
|
||||
assert "MERGED" in markers[0]["content"]
|
||||
|
||||
def test_resume_keeps_the_old_marker_when_rehydration_fails(self):
|
||||
"""If the prior summary can't be recovered, it must not be dropped."""
|
||||
msgs = _conversation(exchanges=10)
|
||||
first = _compressor(summary="IMPORTANT HISTORY: decisions and paths")
|
||||
for _ in range(3):
|
||||
msgs = first._micro_compact(msgs)
|
||||
|
||||
resumed = _compressor(summary="BRAND NEW SUMMARY")
|
||||
resumed._rolling_summary_from_marker = staticmethod(lambda _c: "")
|
||||
result = resumed._micro_compact(msgs)
|
||||
|
||||
markers = _summary_markers(result)
|
||||
assert len(markers) == 2, "must retain the un-carried history"
|
||||
assert any("IMPORTANT HISTORY" in m["content"] for m in markers)
|
||||
|
||||
def test_rolling_summary_round_trips_through_a_marker(self):
|
||||
cc = _compressor()
|
||||
cc._micro_compact_rolling_summary = "decisions: use the existing helper"
|
||||
msgs = _conversation(exchanges=6)
|
||||
result = cc._micro_compact(msgs)
|
||||
marker = _summary_markers(result)[0]
|
||||
|
||||
assert (cc._rolling_summary_from_marker(marker["content"])
|
||||
== cc._micro_compact_rolling_summary)
|
||||
|
||||
def test_short_conversation_is_untouched(self):
|
||||
cc = _compressor()
|
||||
messages = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue