hermes-agent/tests/agent/test_micro_compaction.py
Michael Jordan 186cad02f9 feat(agent): per-turn micro-compaction to amortize context compression
Batch compaction pauses a session for one large summarization once the
window fills. Micro-compaction spreads that cost out: after each completed
turn, `finalize_turn` folds the single oldest un-absorbed exchange
(assistant message plus its tool results) into a rolling summary, so the
work happens in small increments during post-turn idle time instead of one
long stall.

Mechanics:
  - a cursor tracks the first message not yet absorbed, recovered from the
    transcript's last summary marker when in-memory state is unavailable;
  - protected head and tail windows are never touched, so the system prompt
    and recent turns stay verbatim;
  - the absorbed span is replaced by a marker carrying the usual
    `_compressed_summary` metadata, so resume, handoff and `/compress`
    treat it exactly like a batch summary;
  - `archive_and_compact` keeps the session DB in step, otherwise the
    append-only flush would leave the original rows active and a resume
    would double-load both summary and originals;
  - when the rolling summary itself passes a token threshold it is
    defragged: re-summarized in one shot and the cursor jumps to the tail;
  - an exchange the summarizer can't handle is retried a bounded number of
    times, then skipped, so one poison exchange can't stall every turn.

Keep only the newest summary marker. The rolling summary is cumulative, so
each marker already contains everything the previous ones held; leaving them
stacked near-duplicate copies of the same text, each with its own heading and
end-marker scaffolding, and the transcript grew on every turn instead of
shrinking. Measured over six turns on a 12-exchange conversation with tool
output: 4104 -> 4797 tokens before, 4104 -> 2572 after.

Off switch: `compression.micro_compact: false` (default on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:44:19 +05:30

179 lines
6.6 KiB
Python

"""Tests for per-turn micro-compaction in ``ContextCompressor``.
Micro-compaction amortizes the cost of context compression: instead of one
long pause when the window fills, each turn folds the single oldest
un-absorbed exchange into a rolling summary.
The invariants that matter:
* one call absorbs exactly one exchange (assistant + its tool results), so
the per-turn cost stays bounded;
* the absorbed span is replaced by a summary marker carrying the usual
``_compressed_summary`` metadata, so resume/handoff treat it like a batch
summary;
* the cursor advances, so successive calls walk forward rather than
re-summarising the same exchange;
* protected head and tail messages are never touched;
* an exchange the summarizer cannot handle is retried a bounded number of
times and then skipped, so a poison exchange can't stall every turn.
"""
import pytest
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
ContextCompressor,
_MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES,
)
def _compressor(summary="ROLLING SUMMARY") -> ContextCompressor:
cc = ContextCompressor(
model="test-model",
threshold_percent=0.75,
protect_first_n=1,
protect_last_n=2,
quiet_mode=True,
config_context_length=40960,
provider="test",
)
cc._micro_compact_enabled = True
# Stand in for the auxiliary summarizer LLM.
cc._micro_summarize_one = lambda _text: summary
return cc
def _conversation(exchanges: int = 6) -> list:
msgs = [{"role": "system", "content": "system prompt"}]
for i in range(exchanges):
msgs.append({"role": "user", "content": f"question {i}"})
msgs.append({"role": "assistant", "content": f"answer {i} " + "z" * 400})
return msgs
def _summary_markers(messages: list) -> list:
return [m for m in messages if m.get(COMPRESSED_SUMMARY_METADATA_KEY)]
class TestMicroCompaction:
def test_absorbs_one_exchange_and_leaves_a_summary_marker(self):
cc = _compressor()
messages = _conversation()
result = cc._micro_compact(list(messages))
# The absorbed assistant turn is gone from the transcript.
assert any("answer 0" in str(m.get("content")) for m in messages)
assert not any("answer 0" in str(m.get("content")) for m in result)
markers = _summary_markers(result)
assert len(markers) == 1
assert "ROLLING SUMMARY" in markers[0]["content"]
# The marker stands in for a user turn, like batch compaction's does.
assert markers[0]["role"] == "user"
def test_disabled_is_a_no_op(self):
cc = _compressor()
cc._micro_compact_enabled = False
messages = _conversation()
assert cc._micro_compact(list(messages)) == messages
def test_cursor_advances_across_successive_turns(self):
cc = _compressor()
messages = _conversation(exchanges=8)
first = cc._micro_compact(list(messages))
cursor_after_first = cc._micro_compact_cursor
second = cc._micro_compact(list(first))
assert cursor_after_first > 0
assert cc._micro_compact_cursor >= cursor_after_first
# Still exactly one marker: the second pass merges into the rolling
# summary rather than stacking a second summary block.
assert len(_summary_markers(second)) == 1
def test_protected_head_and_tail_survive(self):
cc = _compressor()
messages = _conversation()
result = cc._micro_compact(list(messages))
assert result[0] == messages[0], "system prompt must be preserved"
assert result[-1] == messages[-1], "most recent turn must be preserved"
def test_short_conversation_is_untouched(self):
cc = _compressor()
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
assert cc._micro_compact(list(messages)) == messages
def test_summarizer_failure_leaves_conversation_intact(self):
cc = _compressor()
cc._micro_summarize_one = lambda _text: None
messages = _conversation()
result = cc._micro_compact(list(messages))
assert result == messages
assert cc._micro_compact_consecutive_failures == 1
def test_poison_exchange_is_skipped_after_repeated_failures(self):
"""A repeatedly unsummarizable exchange must not stall every turn."""
cc = _compressor()
cc._micro_summarize_one = lambda _text: None
messages = _conversation()
for _ in range(_MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES):
cc._micro_compact(list(messages))
# The cursor has moved past the stuck exchange and the strike count
# is reset, so the next turn attempts new material.
assert cc._micro_compact_cursor > 0
assert cc._micro_compact_consecutive_failures == 0
def test_repeated_compaction_shrinks_context_and_keeps_one_marker(self):
"""The whole point: successive turns must reduce the transcript.
The rolling summary is cumulative, so an earlier marker's text is a
subset of the current one. Keeping the earlier markers stacked
near-duplicate copies (each with its own heading/end-marker
scaffolding) and made the transcript grow every turn — the opposite
of what compaction is for.
"""
from agent.model_metadata import estimate_messages_tokens_rough
cc = _compressor()
# Cumulative summary, like the real summarizer produces.
state = {"n": 0}
def growing(_text):
state["n"] += 1
return "SUMMARY " + " ".join(f"ex{i}" for i in range(state["n"]))
cc._micro_summarize_one = growing
messages = _conversation(exchanges=12)
before = estimate_messages_tokens_rough(messages)
for _ in range(6):
messages = cc._micro_compact(messages)
after = estimate_messages_tokens_rough(messages)
assert len(_summary_markers(messages)) == 1
assert after < before, f"context grew: {before} -> {after}"
def test_defrag_triggers_once_the_rolling_summary_grows(self):
cc = _compressor(summary="FRESH DEFRAGGED SUMMARY")
cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold
messages = _conversation(exchanges=8)
assert cc._needs_defrag() is True
result = cc._micro_compact(list(messages))
assert cc._micro_compact_rolling_summary == "FRESH DEFRAGGED SUMMARY"
markers = _summary_markers(result)
assert len(markers) == 1
assert "FRESH DEFRAGGED SUMMARY" in markers[0]["content"]