diff --git a/anton/core/session.py b/anton/core/session.py index 77eece9a..11bbf44d 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -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)) @@ -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, @@ -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") @@ -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 @@ -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). diff --git a/tests/test_history_compaction.py b/tests/test_history_compaction.py new file mode 100644 index 00000000..c9c3ae84 --- /dev/null +++ b/tests/test_history_compaction.py @@ -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