Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 65 additions & 14 deletions anton/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@ def __init__(self, config: ChatSessionConfig) -> None:
self._history: list[dict] = (
list(config.initial_history) if config.initial_history else []
)
# Seed length + how many leading history messages the last compaction
# folded into its summary — lets a host map the compaction back onto
# its own message list (see `last_compaction`).
self._seed_len = len(self._history)
self._last_compacted_count: int | None = None
self._pending_memory_confirmations: list = []
self._turn_count = (
sum(1 for m in self._history if is_user_turn(m))
Expand Down Expand Up @@ -484,6 +489,23 @@ def _acc_has_similar(rule: str) -> bool:
def history(self) -> list[dict]:
return self._history

@property
def last_compaction(self) -> dict | None:
"""Result of the last history compaction this session ran, or None.

`summary`: the compacted message content, verbatim (re-seed it as
history[0] next turn so it gets recognized and extended, not
resummarized). `covered_through`: how many of `initial_history`'s
messages are now folded into it — map this count onto your own
message list to find the cutoff.
"""
if self._last_compacted_count is None:
return None
return {
"summary": self._history[0]["content"],
"covered_through": min(self._last_compacted_count, self._seed_len),
}

def _apply_error_tracking(
self,
result_text: str,
Expand Down Expand Up @@ -983,17 +1005,19 @@ async def _summarize_history(self) -> None:
return # Too short to summarize

min_recent = 4
split = max(int(len(self._history) * 0.6), 1)
# Number of leading messages to fold into the summary; doubles as the
# cut index (old = history[:compacted_count], recent = the rest).
compacted_count = max(int(len(self._history) * 0.6), 1)
# Ensure we keep at least min_recent turns
split = min(split, len(self._history) - min_recent)
if split < 2:
compacted_count = min(compacted_count, len(self._history) - min_recent)
if compacted_count < 2:
return

# Walk split backward to avoid breaking tool_use / tool_result pairs.
# A user message containing tool_result blocks must stay with the
# preceding assistant message that contains the matching tool_use.
while split > 1:
msg = self._history[split]
# Walk the cut backward to avoid breaking tool_use / tool_result
# pairs. A user message containing tool_result blocks must stay with
# the preceding assistant message that contains the matching tool_use.
while compacted_count > 1:
msg = self._history[compacted_count]
if msg.get("role") != "user":
break
content = msg.get("content")
Expand All @@ -1006,17 +1030,43 @@ async def _summarize_history(self) -> None:
break
# This user message has tool_results — keep it (and its paired
# assistant message) in the recent portion.
split -= 1
compacted_count -= 1
# Also pull back over the preceding assistant message so the
# pair stays together.
if split > 1 and self._history[split].get("role") == "assistant":
split -= 1
if compacted_count > 1 and self._history[compacted_count].get("role") == "assistant":
compacted_count -= 1

if split < 2:
if compacted_count < 2:
return

old_turns = self._history[:split]
recent_turns = self._history[split:]
old_turns = self._history[:compacted_count]
recent_turns = self._history[compacted_count:]

# A prior summary carried forward from an earlier compaction isn't
# new material — exclude it so re-summarizing a barely-grown summary
# doesn't look worthwhile.
new_old_turns = old_turns
if (
old_turns
and isinstance(old_turns[0].get("content"), str)
and old_turns[0]["content"].lstrip().startswith(_COMPACTED_MARKER)
):
new_old_turns = old_turns[1:]

def _approx_len(msgs: list[dict]) -> int:
total = 0
for m in msgs:
content = m.get("content", "")
total += len(content) if isinstance(content, str) else len(str(content))
return total

# Skip the LLM round-trip when the genuinely-new old turns are under
# ~10% of what we'd re-send — folding them in wouldn't shrink history
# enough to be worth the summarization call.
new_old_len = _approx_len(new_old_turns)
recent_len = _approx_len(recent_turns)
if new_old_len < 0.10 * (new_old_len + recent_len):
return

# Serialize old turns. Pull out any prior compacted summary so we
# UPDATE it in place rather than summarize a summary (which compounds
Expand Down Expand Up @@ -1107,6 +1157,7 @@ async def _summarize_history(self) -> None:
f"{summary}"
)
summary_msg = {"role": "user", "content": summary_body}
self._last_compacted_count = compacted_count

# If the recent portion starts with a user message, insert a minimal
# assistant separator to avoid consecutive user messages (API error).
Expand Down
104 changes: 104 additions & 0 deletions tests/test_history_compaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for the persisted-summary compaction result (ENG-664).

Covers `ChatSession.last_compaction` (what a host reads to persist the
summary + cutoff) and the "skip if there's little new material" guard in
`_summarize_history`. The once-per-turn guard (`_compacted_this_turn`) is
already covered by `TestContextCompaction` in test_chat.py.
"""

from __future__ import annotations

from unittest.mock import AsyncMock

from anton.core.session import ChatSession, ChatSessionConfig, _COMPACTED_MARKER
from anton.core.llm.provider import LLMResponse, Usage
from tests.conftest import make_mock_llm


def _msg(role: str, text: str) -> dict:
return {"role": role, "content": text}


def _summarize_response(text: str) -> LLMResponse:
return LLMResponse(content=text, usage=Usage(input_tokens=10, output_tokens=20))


def _alternating_history(n: int, body: str) -> list[dict]:
return [_msg("user" if i % 2 == 0 else "assistant", body) for i in range(n)]


class TestLastCompaction:
async def test_none_before_any_compaction(self):
session = ChatSession(ChatSessionConfig(
llm_client=make_mock_llm(), initial_history=_alternating_history(10, "hi"),
))
assert session.last_compaction is None

async def test_populated_after_compaction(self):
original = _alternating_history(10, "x" * 50)
mock_llm = make_mock_llm()
mock_llm.summarize = AsyncMock(return_value=_summarize_response("## Goal\nTest goal"))

session = ChatSession(ChatSessionConfig(llm_client=mock_llm, initial_history=original))
await session._summarize_history()

# split = min(int(10*0.6), 10-4) = 6 — matches the plain 60/40 cut since
# none of these messages carry tool_result blocks.
compaction = session.last_compaction
assert compaction is not None
assert compaction["covered_through"] == 6
assert compaction["summary"].startswith(_COMPACTED_MARKER)
assert "## Goal\nTest goal" in compaction["summary"]
# history[0] is exactly the summary reported by last_compaction.
assert session.history[0]["content"] == compaction["summary"]
# The last 4 (uncompacted) turns survive verbatim, in order.
assert session.history[-4:] == original[6:]


class TestSkipWhenLittleNewMaterial:
async def test_skips_llm_call_when_old_turns_are_negligible(self):
"""Old turns tiny, recent turns huge — folding them in wouldn't
meaningfully shrink history, so don't pay for the LLM round-trip."""
history = _alternating_history(6, "ok") + _alternating_history(4, "y" * 5000)
mock_llm = make_mock_llm()
mock_llm.summarize = AsyncMock(return_value=_summarize_response("summary"))

session = ChatSession(ChatSessionConfig(llm_client=mock_llm, initial_history=history))
await session._summarize_history()

mock_llm.summarize.assert_not_called()
assert session.last_compaction is None
assert session.history == history

async def test_prior_summary_excluded_from_new_material_measurement(self):
"""A leading prior-summary entry doesn't count as "new" — if the
actually-new old turns are negligible next to what stays verbatim,
skip even though the prior summary itself is large."""
prior_summary = _msg("user", f"{_COMPACTED_MARKER}\n" + ("z" * 5000))
new_old_turns = _alternating_history(4, "ok")[1:] # tiny "new" material
recent_turns = _alternating_history(4, "y" * 5000)
history = [prior_summary, *new_old_turns, *recent_turns]
mock_llm = make_mock_llm()
mock_llm.summarize = AsyncMock(return_value=_summarize_response("summary"))

session = ChatSession(ChatSessionConfig(llm_client=mock_llm, initial_history=history))
await session._summarize_history()

mock_llm.summarize.assert_not_called()
assert session.last_compaction is None

async def test_runs_when_new_material_is_substantial(self):
"""Sanity check for the guard's threshold: same shape as the skip
test above, but the new old turns are large enough to be worth it."""
prior_summary = _msg("user", f"{_COMPACTED_MARKER}\nprior")
new_old_turns = _alternating_history(4, "x" * 5000)[1:]
recent_turns = _alternating_history(4, "y" * 100)
history = [prior_summary, *new_old_turns, *recent_turns]
mock_llm = make_mock_llm()
mock_llm.summarize = AsyncMock(return_value=_summarize_response("updated summary"))

session = ChatSession(ChatSessionConfig(llm_client=mock_llm, initial_history=history))
await session._summarize_history()

mock_llm.summarize.assert_called_once()
assert session.last_compaction is not None
Loading