hermes-agent/tests/agent/test_micro_compaction.py
kshitijk4poor b8bfd68af1 fix(agent): make micro-compaction alternation-safe and defrag user-preserving
Two integration bugs found during review of #74522, both confirmed with
empirical probes against the production message-repair path:

1. Alternation: the summary marker was role="user" and an exchange was a
   single assistant+tools group, so splicing between two user turns produced
   user -> marker(user) -> user. The pre-request repair_message_sequence pass
   (conversation_loop.py, runs before EVERY API call) then merged the marker
   into the neighbouring real user message: metadata gone, cursor
   unrecoverable on resume, and the summary text duplicated into the
   transcript on every later pass (the transcript GREW every turn).
   Fix: an exchange is now a full agent turn (assistant + tools + follow-up
   assistant iterations, bounded by user messages), the marker is
   assistant-role, and superseding an old marker deliberately merges the two
   adjacent real user turns (plain-text \n\n-join, identical to repair
   pass 2) so the returned transcript is alternation-valid by construction.
   Probe result: repairs 0 (was 2), marker survives, no summary leakage.

2. Defrag destroyed user messages: _defrag_rolling_summary serialized the
   whole remaining middle (user turns included) and spliced it away —
   8 of 10 user prompts destroyed in one pass, contradicting the feature's
   "your messages are never compacted" invariant. Fix: defrag now
   re-summarizes only the rolling summary TEXT and rewrites the marker
   content in place; transcript shape, cursor, and user turns untouched.
   Probe result: 10 of 10 user prompts survive.

Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False —
micro markers absorb only assistant/tool content (#64650 invariant), and
real user turns remain in the transcript for provenance detection.

Adds 5 regression tests (repair-pass integration, alternation on
multi-iteration tool turns, defrag user survival, defrag input scope,
marker provenance); updates the two existing tests and the design doc to
the corrected semantics. 28 tests pass.
2026-07-31 17:44:19 +05:30

633 lines
26 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.
"""
from unittest.mock import patch
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 is assistant-role: an exchange is a full agent turn
# bounded by user messages, so user → marker(assistant) → user keeps
# strict alternation. A user-role marker produced user → user → user,
# and the pre-request repair_message_sequence pass merged the marker
# into the neighbouring real user turn — metadata gone, cursor lost.
assert markers[0]["role"] == "assistant"
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_is_off_unless_explicitly_enabled(self):
# A pass rewrites already-sent history, breaking the prompt-cache
# prefix, so nobody inherits it from an update: it stays off until
# `compression.micro_compact` opts in.
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_summarize_one = lambda _text: "ROLLING SUMMARY"
messages = _conversation()
assert cc._micro_compact_enabled is False
assert cc._micro_compact(list(messages)) == messages
def test_cadence_of_one_runs_every_turn(self):
cc = _compressor()
cc._micro_compact_every_n_turns = 1
messages = _conversation(exchanges=8)
first = cc._micro_compact(list(messages))
second = cc._micro_compact(list(first))
assert cc._micro_compact_cursor > 0
assert len(_summary_markers(first)) == 1
# Each turn absorbed something, so the transcript kept shrinking.
assert len(second) < len(first)
def test_cadence_skips_turns_until_a_pass_is_due(self):
cc = _compressor()
cc._micro_compact_every_n_turns = 3
messages = _conversation(exchanges=8)
first = cc._micro_compact(list(messages))
second = cc._micro_compact(list(first))
# Cache prefix untouched on the turns in between.
assert _summary_markers(first) == []
assert _summary_markers(second) == []
assert cc._micro_compact_cursor == 0
third = cc._micro_compact(list(second))
assert len(_summary_markers(third)) == 1
assert not any("answer 0" in str(m.get("content")) for m in third)
# Counter rearmed for the next window.
assert cc._micro_compact_turns_since_pass == 0
def test_cadence_is_clamped_to_at_least_one(self):
# A bogus 0 or negative must not disable compaction silently, nor
# divide-by-zero: it degrades to "every turn".
for bogus in (0, -5):
cc = _compressor()
cc._micro_compact_every_n_turns = bogus
messages = _conversation(exchanges=8)
result = cc._micro_compact(list(messages))
assert len(_summary_markers(result)) == 1
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_user_messages_are_never_absorbed(self):
"""Every byte the user typed stays in the transcript — by design.
Assistant output is largely an account of what was done and survives
summarising; the user's own words are the intent everything else is
derived from and can't be reconstructed from it. So an exchange starts
at the assistant message and the walk skips past user turns.
The invariant is on user TEXT, not message-list shape: superseding an
old marker leaves two real user turns adjacent, and they are merged
(\\n\\n-joined, same as repair_message_sequence pass 2) to keep strict
alternation. Text is never summarized or dropped.
"""
cc = _compressor()
messages = _conversation(exchanges=10)
originals = [m["content"] for m in messages if m["role"] == "user"]
for _ in range(5):
messages = cc._micro_compact(messages)
surviving_text = "\n\n".join(
m["content"] for m in messages
if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
)
for original in originals:
assert original in surviving_text, (
f"user text {original!r} must survive verbatim"
)
def test_cursor_is_derived_from_the_spliced_list(self):
"""The cursor must never carry over a pre-splice index.
A splice collapses an assistant plus its tool results -- often several
messages -- into one marker, so every later index shifts. Reusing
``exchange_end`` left the cursor pointing inside a *later* exchange's
tool group; the next pass walked forward to the following assistant
and skipped that exchange entirely, so on tool-bearing conversations
roughly half the work silently never happened.
Tool-free fixtures cannot catch this: the span is one message, so
nothing shifts.
"""
cc = _compressor()
msgs = [{"role": "system", "content": "sys"}]
for i in range(8):
msgs.append({"role": "user", "content": f"q{i}"})
msgs.append({
"role": "assistant",
"content": f"a{i}",
"tool_calls": [
{"id": f"c{i}-{j}", "type": "function",
"function": {"name": "f", "arguments": "{}"}}
for j in range(3)
],
})
for j in range(3):
msgs.append({"role": "tool", "tool_call_id": f"c{i}-{j}",
"content": "T" * 500})
for _ in range(4):
msgs = cc._micro_compact(msgs)
marker_idx = next(
i for i, m in enumerate(msgs)
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
)
assert cc._micro_compact_cursor == marker_idx + 1, (
"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 = [
{"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_emits_content_free_token_telemetry(self, caplog):
"""Each pass logs one JSON line with the token accounting.
Message counts barely move even when the saving is large, so the token
fields are what make the effect measurable in a real session.
"""
import json
import logging
cc = _compressor()
messages = _conversation(exchanges=8)
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
result = cc._micro_compact(messages)
lines = [
r.getMessage() for r in caplog.records
if "micro compaction telemetry:" in r.getMessage()
]
assert len(lines) == 1
payload = json.loads(lines[0].split("micro compaction telemetry: ", 1)[1])
assert payload["event"] == "micro_compaction"
assert payload["outcome"] == "absorbed"
assert payload["tokens_saved_total"] == -payload["tokens_delta"]
assert payload["passes_total"] == 1
assert payload["messages_after"] == len(result)
assert payload["exchange_tokens"] > 0
# Content-free: no transcript text may ride along in the payload.
blob = json.dumps(payload)
assert "answer 0" not in blob and "question 0" not in blob
def test_telemetry_reports_occupancy_without_forcing_resolution(self, caplog):
"""Occupancy is the headline: how full the window is being kept.
It must be read from the cached threshold only. The public
``threshold_tokens`` property resolves lazily and can fire a
synchronous /models probe (#32221); telemetry must never be what
blocks a turn, so an unresolved window reports null instead.
"""
import json
import logging
cc = _compressor()
cc.threshold_tokens = 10_000 # pin; also populates the cache
messages = _conversation(exchanges=8)
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
cc._micro_compact(messages)
line = next(r.getMessage() for r in caplog.records
if "micro compaction telemetry:" in r.getMessage())
payload = json.loads(line.split("micro compaction telemetry: ", 1)[1])
assert payload["threshold_tokens"] == 10_000
assert payload["occupancy_pct"] == pytest.approx(
payload["tokens_after"] / 10_000 * 100, abs=0.1
)
def test_emitter_never_forces_window_resolution(self, caplog):
"""The emitter reads the cached threshold, never the property.
In a real pass the threshold is already resolved by the time
telemetry runs (the tail calculation needs it), so occupancy is
normally populated. This pins the safety property directly: with the
cache empty, emitting reports null rather than triggering the lazy
resolution — which can issue a synchronous /models probe (#32221).
"""
import json
import logging
cc = _compressor()
cc._threshold_tokens = None
cc._resolved_context_length = None
def explode(self): # pragma: no cover - must never be called
raise AssertionError("telemetry forced context-length resolution")
with patch.object(type(cc), "threshold_tokens",
property(explode, lambda s, v: None)):
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
cc._emit_micro_compaction_telemetry(
outcome="absorbed",
messages_before=10,
messages_after=9,
tokens_before=500,
tokens_after=400,
)
line = next(r.getMessage() for r in caplog.records
if "micro compaction telemetry:" in r.getMessage())
payload = json.loads(line.split("micro compaction telemetry: ", 1)[1])
assert payload["occupancy_pct"] is None
assert payload["threshold_tokens"] is None
def test_first_pass_costs_marker_overhead_then_pays_it_back(self):
"""The first pass can grow the transcript; later passes recover it.
Inserting the summary marker costs a fixed ~400 tokens of scaffolding
(the compaction preamble, the historical heading and the end marker).
On pass one that overhead is paid against a single absorbed exchange,
so the net can be positive. From pass two on the marker is replaced
rather than added, so the scaffolding is already paid for and each
absorbed exchange is pure saving. Anyone reading a single turn's
telemetry needs to know this before concluding it made things worse.
"""
from agent.model_metadata import estimate_messages_tokens_rough
cc = _compressor()
messages = _conversation(exchanges=10)
start = estimate_messages_tokens_rough(messages)
messages = cc._micro_compact(messages)
after_first = estimate_messages_tokens_rough(messages)
for _ in range(5):
messages = cc._micro_compact(messages)
after_many = estimate_messages_tokens_rough(messages)
assert after_first > start, "expected one-time marker overhead"
assert after_many < after_first, "later passes must recover it"
def test_cumulative_savings_accumulate_across_passes(self):
cc = _compressor()
messages = _conversation(exchanges=10)
for _ in range(4):
messages = cc._micro_compact(messages)
assert cc._micro_compact_passes == 4
assert cc._micro_compact_tokens_saved_total > 0
def test_defrag_triggers_once_the_rolling_summary_grows(self):
"""Defrag rewrites the summary text and the marker — nothing else.
The original implementation spliced the whole remaining middle (user
turns included) into the marker, silently absorbing user messages.
Defrag is now transcript-shape-neutral: same message list, same
cursor, marker content rewritten in place.
"""
cc = _compressor(summary="FRESH DEFRAGGED SUMMARY")
messages = _conversation(exchanges=8)
# Seed a real marker + oversized rolling summary, as after many passes.
messages = cc._micro_compact(list(messages))
cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold
cursor_before = cc._micro_compact_cursor
shape_before = [m.get("role") for m in messages]
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"]
# Shape-neutral: no messages absorbed or spliced, cursor unmoved.
assert [m.get("role") for m in result] == shape_before
assert cc._micro_compact_cursor == cursor_before
def test_defrag_never_absorbs_user_messages(self):
"""Defrag must not touch user turns — the feature's core invariant.
The original implementation serialized head..tail (user turns
included) and spliced it away: 8 of 10 user prompts were destroyed in
one pass. Defrag now only rewrites the rolling summary text.
"""
cc = _compressor(summary="DEFRAGGED")
messages = [{"role": "system", "content": "sys"}]
for i in range(10):
messages.append({"role": "user", "content": f"UNIQUE-USER-PROMPT-{i}"})
messages.append({"role": "assistant", "content": f"answer {i} " + "z" * 400})
cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag
result = cc._micro_compact(list(messages))
surviving = [
m["content"] for m in result
if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
for i in range(10):
assert any(f"UNIQUE-USER-PROMPT-{i}" in s for s in surviving), (
f"user prompt {i} was absorbed by defrag"
)
def test_defrag_summarizes_only_the_summary_text(self):
"""The defrag aux call receives the rolling summary, not the transcript."""
cc = _compressor()
captured = {}
def capture(text):
captured["text"] = text
return "DEFRAGGED"
cc._micro_summarize_one = capture
cc._micro_compact_rolling_summary = "OLD-SUMMARY " + "x" * 40_000
messages = _conversation(exchanges=8)
cc._micro_compact(list(messages))
assert "OLD-SUMMARY" in captured["text"]
assert "[USER]" not in captured["text"], (
"defrag must never serialize transcript user turns"
)
def test_spliced_transcript_survives_repair_message_sequence(self):
"""The compacted transcript must survive the production repair pass.
conversation_loop runs repair_message_sequence before EVERY API call.
With the old user-role marker, splicing next to a real user turn made
user → user → user; repair merged the marker into the real user
message — metadata gone, cursor unrecoverable, summary text duplicated
into the transcript on every later pass. Pin the integration: markers
survive repair untouched, and no summary text leaks into real user
messages.
"""
from agent.agent_runtime_helpers import repair_message_sequence
class _DummyAgent:
session_id = "probe"
_last_flushed_db_idx = 0
cc = _compressor()
messages = _conversation(exchanges=8)
for _ in range(3):
messages = cc._micro_compact(messages)
repairs = repair_message_sequence(_DummyAgent(), messages)
assert repairs == 0, (
"micro-compacted transcript must already be alternation-valid"
)
markers = _summary_markers(messages)
assert len(markers) == 1, "marker destroyed by repair pass"
polluted = [
m for m in messages
if m.get("role") == "user"
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
and "ROLLING SUMMARY" in str(m.get("content"))
]
assert not polluted, "summary text leaked into a real user message"
def test_spliced_transcript_has_no_consecutive_same_role_messages(self):
"""Alternation invariant, checked directly on tool-bearing turns."""
cc = _compressor()
msgs = [{"role": "system", "content": "sys"}]
for i in range(8):
msgs.append({"role": "user", "content": f"q{i}"})
msgs.append({
"role": "assistant",
"content": f"a{i}",
"tool_calls": [
{"id": f"c{i}-{j}", "type": "function",
"function": {"name": "f", "arguments": "{}"}}
for j in range(2)
],
})
for j in range(2):
msgs.append({"role": "tool", "tool_call_id": f"c{i}-{j}",
"content": "T" * 400})
# Multi-iteration turn: a second assistant+tools group before the
# next user message — the splice must absorb the WHOLE turn.
msgs.append({"role": "assistant", "content": f"followup {i} " + "y" * 200})
for _ in range(4):
msgs = cc._micro_compact(msgs)
for a, b in zip(msgs, msgs[1:]):
ra, rb = a.get("role"), b.get("role")
assert not (ra == rb and ra in ("user", "assistant")), (
f"consecutive {ra} messages after micro-compaction"
)
def test_marker_reports_no_user_provenance(self):
"""Micro markers absorb only assistant/tool content (#64650)."""
from agent.context_compressor import COMPRESSED_SUMMARY_HAS_USER_TURN_KEY
cc = _compressor()
result = cc._micro_compact(_conversation(exchanges=6))
marker = _summary_markers(result)[0]
assert marker[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False