955 lines
40 KiB
Python
955 lines
40 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_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_cumulative_savings_accumulate_across_passes(self):
|
|
"""Session-total savings go positive once marker overhead is paid back.
|
|
|
|
The first pass inserts ``SUMMARY_PREFIX`` scaffolding (~450 tokens);
|
|
with the current preamble that alone leaves the cumulative counter
|
|
negative after only a few absorptions. Enough later passes must
|
|
still recover it — that is the amortization contract.
|
|
"""
|
|
cc = _compressor()
|
|
messages = _conversation(exchanges=10)
|
|
|
|
for _ in range(6):
|
|
messages = cc._micro_compact(messages)
|
|
|
|
assert cc._micro_compact_passes == 6
|
|
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
|
|
|
|
def test_supersede_never_drops_a_batch_compaction_marker(self):
|
|
"""A batch marker holds history the rolling summary does NOT contain.
|
|
|
|
Sequence: micro absorbs some exchanges (rolling summary = those k
|
|
exchanges only), then batch compaction fires and its marker
|
|
summarizes MORE (exchanges 1..m). The next micro pass's supersede
|
|
must not treat the batch marker as redundant — dropping it destroys
|
|
everything batch summarized beyond exchange k. Only micro-tagged
|
|
markers (whose text is provably inside the rolling summary) may be
|
|
superseded.
|
|
"""
|
|
cc = _compressor(summary="MICRO SUMMARY (exchanges 1..k only)")
|
|
msgs = _conversation(exchanges=8)
|
|
msgs = cc._micro_compact(msgs)
|
|
assert cc._micro_compact_rolling_summary
|
|
|
|
# Simulate a batch-compaction marker replacing the middle (batch
|
|
# markers carry the shared metadata key but NOT the micro tag).
|
|
batch_marker = {
|
|
"role": "user",
|
|
"content": "[batch summary] CRITICAL HISTORY: exchanges 1..m",
|
|
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
|
}
|
|
micro_idx = next(
|
|
i for i, m in enumerate(msgs)
|
|
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
|
)
|
|
msgs = msgs[:micro_idx] + [batch_marker] + msgs[micro_idx + 3:]
|
|
|
|
out = cc._micro_compact(msgs)
|
|
|
|
assert any(
|
|
"CRITICAL HISTORY" in str(m.get("content")) for m in out
|
|
), "batch-compaction summary destroyed by micro supersede"
|
|
|
|
def test_defrag_never_rewrites_a_batch_compaction_marker(self):
|
|
"""Defrag rewrites only micro-tagged markers, never batch markers."""
|
|
cc = _compressor(summary="DEFRAGGED")
|
|
msgs = [{"role": "system", "content": "sys"}]
|
|
msgs.append({
|
|
"role": "user",
|
|
"content": "[batch summary] CRITICAL HISTORY: exchanges 1..m",
|
|
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
|
})
|
|
for i in range(6):
|
|
msgs.append({"role": "user", "content": f"q{i}"})
|
|
msgs.append({"role": "assistant", "content": f"a{i} " + "z" * 400})
|
|
|
|
cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag
|
|
result = cc._micro_compact(list(msgs))
|
|
|
|
batch = [m for m in result if "CRITICAL HISTORY" in str(m.get("content"))]
|
|
assert batch, "batch marker content overwritten by defrag"
|
|
|
|
def test_batch_compress_resets_micro_state(self):
|
|
"""compress() success path invalidates the stale rolling summary.
|
|
|
|
Without the reset, the in-memory micro summary (exchanges 1..k)
|
|
outlives a batch compaction whose marker covers 1..m — the next
|
|
micro pass would then treat its stale summary as cumulative.
|
|
"""
|
|
cc = _compressor()
|
|
msgs = _conversation(exchanges=8)
|
|
msgs = cc._micro_compact(msgs)
|
|
assert cc._micro_compact_rolling_summary
|
|
assert cc._micro_compact_cursor > 0
|
|
|
|
cc.compress(msgs, force=True)
|
|
|
|
assert cc._micro_compact_rolling_summary == ""
|
|
assert cc._micro_compact_cursor == 0
|
|
|
|
|
|
def test_db_sync_passes_exact_carried_messages(self):
|
|
"""Micro-compaction carries a prefix and suffix around its summary marker.
|
|
|
|
The persistence layer must receive exact durable ids, not len(result)-1:
|
|
a tail count reaches backward across the removed assistant/tool exchange and
|
|
turns summarized rows into rewind-only active=0, compacted=0 debris
|
|
(#118481).
|
|
"""
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
|
|
cc = _compressor()
|
|
captured = {}
|
|
|
|
class _DB:
|
|
def archive_and_compact(self, session_id, messages, **kwargs):
|
|
captured["session_id"] = session_id
|
|
captured["messages"] = messages
|
|
captured["kwargs"] = kwargs
|
|
return len(messages)
|
|
|
|
cc._session_db = _DB()
|
|
cc._session_id = "sess"
|
|
compacted = [
|
|
{"role": "user", "content": "prefix", "_row_id": 11, _DB_PERSISTED_MARKER: True},
|
|
{
|
|
"role": "assistant",
|
|
"content": "summary",
|
|
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
|
},
|
|
{"role": "user", "content": "suffix", "_row_id": 15, _DB_PERSISTED_MARKER: True},
|
|
# Content was rewritten in-place: the mutation contract deliberately
|
|
# popped _DB_PERSISTED_MARKER, so its old row is NOT byte-identical.
|
|
{"role": "assistant", "content": "rewritten", "_row_id": 16},
|
|
]
|
|
|
|
cc._sync_micro_compact_to_db(compacted)
|
|
|
|
assert captured["session_id"] == "sess"
|
|
carried = captured["kwargs"].get("carried_messages")
|
|
assert [message.get("_row_id") for message in carried] == [11, 15]
|
|
assert "tail_count" not in captured["kwargs"]
|
|
|
|
def test_splice_preserves_db_persisted_stamps(self):
|
|
"""Surviving messages keep their _db_persisted stamps through a splice.
|
|
|
|
Micro-compaction archives in place under the SAME session id, so the
|
|
stamps on untouched messages stay accurate. Stripping them (as the
|
|
batch path does for its child-session rotation) meant an
|
|
archive_and_compact failure left every previously-persisted message
|
|
unstamped and the next append-only flush re-inserted them all as
|
|
duplicate active rows.
|
|
"""
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
|
|
cc = _compressor()
|
|
messages = _conversation(exchanges=8)
|
|
for m in messages:
|
|
m[_DB_PERSISTED_MARKER] = True
|
|
|
|
# No DB bound -> _sync_micro_compact_to_db no-ops (the failure shape).
|
|
result = cc._micro_compact(messages)
|
|
|
|
unstamped = [
|
|
m for m in result
|
|
if not m.get(_DB_PERSISTED_MARKER)
|
|
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
|
]
|
|
assert not unstamped, (
|
|
"splice must not strip _db_persisted from surviving messages"
|
|
)
|
|
|
|
|
|
class TestDefragFlushCursorInvalidation:
|
|
"""Sibling of the finalize_turn pop site (#75170): defrag pops
|
|
_DB_PERSISTED_MARKER from the live marker dict in place, so the bounded
|
|
flush-scan cursor must be invalidated or the rewritten summary is
|
|
identity-skipped and never re-persisted."""
|
|
|
|
def _defrag_setup(self):
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
|
|
cc = _compressor(summary="FRESH DEFRAGGED SUMMARY")
|
|
messages = _conversation(exchanges=8)
|
|
messages = cc._micro_compact(list(messages))
|
|
# Simulate an incremental flush having stamped the marker row.
|
|
for m in messages:
|
|
if m.get(COMPRESSED_SUMMARY_METADATA_KEY):
|
|
m[_DB_PERSISTED_MARKER] = True
|
|
cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag
|
|
return cc, messages
|
|
|
|
def test_defrag_marker_pop_raises_invalidation_flag(self):
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
|
|
cc, messages = self._defrag_setup()
|
|
assert cc._flush_scan_cursor_invalidated is False
|
|
|
|
result = cc._micro_compact(list(messages))
|
|
|
|
markers = _summary_markers(result)
|
|
assert len(markers) == 1
|
|
# The pop happened in place on the live dict...
|
|
assert not markers[0].get(_DB_PERSISTED_MARKER)
|
|
# ...so the compressor must flag the flush-scan cursor stale.
|
|
assert cc._flush_scan_cursor_invalidated is True
|
|
|
|
def test_no_defrag_no_flag(self):
|
|
cc = _compressor()
|
|
messages = _conversation(exchanges=8)
|
|
cc._micro_compact(list(messages))
|
|
assert cc._flush_scan_cursor_invalidated is False
|
|
|
|
|
|
|
|
class TestMergeAdjacentUserTurnsPersistedMarker:
|
|
"""Third pop site under the _DB_PERSISTED_MARKER contract: a supersede that
|
|
drops a stale micro marker can leave two persisted plain user dicts adjacent;
|
|
the merge rewrites the earlier one's content in place, so the stamp must be
|
|
popped and the flush-scan cursor invalidated or the merged text is
|
|
identity-skipped and never reaches state.db."""
|
|
|
|
def _spliced_merge(self):
|
|
from agent.context_compressor import (
|
|
_DB_PERSISTED_MARKER,
|
|
COMPRESSED_SUMMARY_METADATA_KEY,
|
|
MICRO_COMPACT_MARKER_KEY,
|
|
)
|
|
|
|
cc = _compressor()
|
|
cc._micro_compact_rolling_summary = "ROLLING"
|
|
u1 = {"role": "user", "content": "first", _DB_PERSISTED_MARKER: True}
|
|
stale_micro = {
|
|
"role": "assistant",
|
|
"content": "SUMMARY",
|
|
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
|
MICRO_COMPACT_MARKER_KEY: True,
|
|
_DB_PERSISTED_MARKER: True,
|
|
}
|
|
u2 = {"role": "user", "content": "second", _DB_PERSISTED_MARKER: True}
|
|
exchange = [
|
|
{"role": "assistant", "content": "answer", _DB_PERSISTED_MARKER: True},
|
|
{"role": "user", "content": "third", _DB_PERSISTED_MARKER: True},
|
|
]
|
|
messages = [u1, stale_micro, u2, *exchange]
|
|
# Dropping stale_micro leaves u1/u2 adjacent; the exchange is spliced out.
|
|
return cc, cc._splice_micro_compact_result(messages, 3, 5, supersede=True)
|
|
|
|
def test_merge_pops_persisted_marker_and_raises_flag(self):
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
|
|
cc, result = self._spliced_merge()
|
|
|
|
merged = [m for m in result if m.get("role") == "user"]
|
|
assert len(merged) == 1
|
|
assert merged[0]["content"] == "first\n\nsecond"
|
|
assert _DB_PERSISTED_MARKER not in merged[0]
|
|
assert cc._flush_scan_cursor_invalidated is True
|
|
|
|
|
|
def test_superseding_marker_never_shows_a_user_input_twice_in_display_history(tmp_path):
|
|
"""A supersede merges the adjacent user turns for the model; the originals stay in display
|
|
history as compacted rows, so no display projection (resume, REST page, legacy page, prompt
|
|
timeline) may also paint the merged row. The model view still holds each input exactly once."""
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
from hermes_state import SessionDB
|
|
from hermes_state_timeline import get_session_timeline
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("s", source="cli")
|
|
messages = _conversation(exchanges=8)
|
|
for i, msg in enumerate(messages):
|
|
msg["timestamp"] = 1000.0 + i
|
|
msg["_row_id"] = db.append_message("s", role=msg["role"], content=msg["content"], timestamp=msg["timestamp"])
|
|
msg[_DB_PERSISTED_MARKER] = True
|
|
cc = _compressor()
|
|
cc._session_db, cc._session_id = db, "s"
|
|
for _ in range(4):
|
|
messages = cc._micro_compact(messages)
|
|
assert len(_summary_markers(messages)) == 1
|
|
assert any("question 0\n\nquestion 1" in str(m.get("content")) for m in messages), "no supersede merge happened"
|
|
|
|
def shown(msgs):
|
|
return sorted(t for m in msgs if m.get("role") == "user" for t in str(m["content"]).split("\n\n"))
|
|
|
|
typed = sorted(m["content"] for m in _conversation(exchanges=8) if m["role"] == "user")
|
|
model, display = db.get_resume_conversations("s")
|
|
assert shown(display) == typed
|
|
assert shown(model) == typed
|
|
assert shown(db.get_messages("s", include_compacted=True)) == typed
|
|
timeline = [e["preview"] for e in get_session_timeline(db, "s")["entries"]]
|
|
assert sorted(timeline) == typed
|
|
# Legacy stores (no display index, read-only so no backfill) take the payload-bounded page.
|
|
db._execute_write(lambda conn: conn.execute("UPDATE messages SET display_order = NULL"))
|
|
legacy = SessionDB(db_path=tmp_path / "state.db", read_only=True)
|
|
try:
|
|
assert shown(legacy.get_messages("s", include_compacted=True)) == typed
|
|
assert sorted(e["preview"] for e in get_session_timeline(legacy, "s")["entries"]) == typed
|
|
finally:
|
|
legacy.close()
|
|
db.close()
|
|
|
|
|
|
def _held_session(tmp_path, mode: str):
|
|
"""A real SessionDB session plus the compressor and the history it holds (as a resume restores it:
|
|
row ids and persisted markers included) for a prune or a micro-compaction pass."""
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("s", source="cli")
|
|
cc = _compressor()
|
|
cc._session_db, cc._session_id = db, "s"
|
|
if mode == "prune":
|
|
cc.proactive_prune_tokens, cc.proactive_prune_min_result_chars = 48_000, 8_000
|
|
cc.proactive_prune_min_reclaim_tokens, cc.protect_first_n, cc.protect_last_n = 4_096, 2, 4
|
|
history = [{"role": "user", "content": "start"}]
|
|
for i in range(8):
|
|
history.append({"role": "assistant", "content": "", "tool_calls": [{
|
|
"id": f"call_{i}", "type": "function", "function": {"name": "terminal", "arguments": "{}"}}]})
|
|
history.append({"role": "tool", "tool_call_id": f"call_{i}", "content": chr(65 + i) * 24_000 if i < 3 else "ok"})
|
|
else:
|
|
history = _conversation(exchanges=8)
|
|
db.append_messages_batch("s", history)
|
|
return db, cc, db.get_resume_conversations("s")[0]
|
|
|
|
|
|
def _run_pass(cc, mode: str, held: list) -> list:
|
|
if mode == "prune":
|
|
return cc.prune_tool_results_only(held, current_tokens=120_000)[0]
|
|
return cc._micro_compact(held)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["prune", "micro"])
|
|
def test_a_turn_another_surface_appended_after_load_is_kept_exactly_once(tmp_path, mode):
|
|
"""Prune and micro-compaction rewrite the history this process holds. A turn another surface appended to
|
|
the same session since then (a Desktop session continued from Telegram) was archived with the rest, as
|
|
summarized away though no summary holds it: still displayed and searchable, gone from the model's view."""
|
|
db, cc, held = _held_session(tmp_path, mode)
|
|
foreign = "[from Telegram] the vault code is 7741"
|
|
db.append_message("s", "user", foreign)
|
|
|
|
result = _run_pass(cc, mode, held)
|
|
|
|
assert result is not held # a pass ran and committed
|
|
live = [str(m["content"]) for m in db.get_messages_as_conversation("s")]
|
|
assert live[-1] == foreign
|
|
assert sum(foreign in content for content in live) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("mode,race", [
|
|
("prune", "before"), ("micro", "before"), ("micro", "during_summary"),
|
|
("prune", "watermark_read_fails"), ("micro", "watermark_read_fails"),
|
|
])
|
|
def test_a_stale_generation_aborts_leaving_the_winner_the_only_live_version(tmp_path, mode, race):
|
|
"""Prune and micro-compaction hold no compression lease. Once another compaction has committed (before the
|
|
pass, or while micro-compaction waits on its summary call), the held history is a stale generation:
|
|
publishing it archives the winner's rows and clones them back beside a second summary, and returning the
|
|
spliced list lets the finalizer's persist flush append the stale summary row as live. The pass must be a
|
|
true no-op: the input list, the pre-pass rolling summary, and a store holding only the winner. A failed
|
|
watermark read must take the same no-op path, never a None watermark (which archives every active row)."""
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
|
|
db, cc, held = _held_session(tmp_path, mode)
|
|
winner = [
|
|
{"role": "user", "content": "start"},
|
|
{"role": "assistant", "content": "[winner summary] the earlier turns"},
|
|
{"role": "user", "content": "[from Telegram] carry on"},
|
|
]
|
|
if race == "watermark_read_fails":
|
|
winner = [{"role": m["role"], "content": m["content"]} for m in held] # nothing may be archived
|
|
|
|
def _watermark_read_fails(_session_id):
|
|
raise RuntimeError("database is locked")
|
|
db.get_active_message_watermark = _watermark_read_fails
|
|
elif race == "before":
|
|
db.archive_and_compact("s", winner)
|
|
else:
|
|
def _summary_while_another_surface_compacts(_text):
|
|
db.archive_and_compact("s", winner)
|
|
return "STALE ROLLING SUMMARY"
|
|
cc._micro_summarize_one = _summary_while_another_surface_compacts
|
|
summary = cc._micro_compact_rolling_summary
|
|
|
|
result = _run_pass(cc, mode, held)
|
|
for msg in result: # what finalize_turn's _persist_session flush appends: every unpersisted dict
|
|
if not msg.get(_DB_PERSISTED_MARKER):
|
|
db.append_message("s", msg["role"], msg.get("content"))
|
|
|
|
assert result is held
|
|
assert cc._micro_compact_rolling_summary == summary # the stale summary is not carried into the next pass
|
|
live = [m["content"] for m in db.get_messages_as_conversation("s")]
|
|
assert live == [m["content"] for m in winner] # exactly one live generation
|