diff --git a/agent/context_compressor.py b/agent/context_compressor.py index f6f0556e713b..16789229dbac 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -156,6 +156,11 @@ _FALLBACK_TURN_MAX_CHARS = 700 _AUTO_FOCUS_MAX_TURNS = 3 _AUTO_FOCUS_TURN_MAX_CHARS = 260 _AUTO_FOCUS_MAX_CHARS = 700 +# Keep a short run of recent messages verbatim even when the token budget is +# already exhausted. The public ``protect_last_n`` default is intentionally +# high for small/light tails, but using all 20 as a hard floor here would bring +# back the old large-tool-output case where nothing can be compacted. +_MAX_TAIL_MESSAGE_FLOOR = 8 _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") @@ -1990,11 +1995,12 @@ This compaction should PRIORITISE preserving all information related to the focu derived from ``summary_target_ratio * context_length``, so it scales automatically with the model's context window. - Token budget is the primary criterion. A hard minimum of 3 messages - is always protected, but the budget is allowed to exceed by up to - 1.5x to avoid cutting inside an oversized message (tool output, file - read, etc.). If even the minimum 3 messages exceed 1.5x the budget - the cut is placed right after the head so compression still runs. + Token budget is the primary criterion. A bounded message-count floor + keeps a short run of recent turns verbatim even when the budget is + exhausted, but the budget is allowed to exceed by up to 1.5x to avoid + cutting inside an oversized message (tool output, file read, etc.). If + even that floor exceeds 1.5x the budget, the cut is placed right after + the head so compression still runs. Never cuts inside a tool_call/result group. Always ensures the most recent user message is in the tail (see ``_ensure_last_user_message_in_tail``). @@ -2002,8 +2008,19 @@ This compaction should PRIORITISE preserving all information related to the focu if token_budget is None: token_budget = self.tail_token_budget n = len(messages) - # Hard minimum: always keep at least 3 messages in the tail - min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0 + # Hard minimum: always keep a bounded recent-message floor in the tail. + # ``protect_last_n`` remains a minimum up to the cap; the cap avoids + # preserving a whole run of bulky tool outputs on every compaction. + available_tail = max(0, n - head_end - 1) + min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR)) + # Leave at least two non-head messages available to summarize on short + # transcripts; otherwise compression can replace a tiny middle with a + # summary and save no messages at all. + compressible_tail_cap = max(3, available_tail - 2) + min_tail = ( + min(min_tail_floor, compressible_tail_cap, available_tail) + if available_tail > 1 else 0 + ) soft_ceiling = int(token_budget * 1.5) accumulated = 0 cut_idx = n # start from beyond the end diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index b121192bd170..7eb1e8a57b02 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1804,6 +1804,40 @@ class TestTokenBudgetTailProtection: tail_size = len(messages) - cut assert tail_size >= 3, f"Tail is only {tail_size} messages, min should be 3" + def test_tiny_budget_preserves_bounded_recent_turns(self, budget_compressor): + """A token-exhausted tail must preserve more than just the latest ask. + + Regression for #9413: the previous hard-coded 3-message floor could + leave the latest user message live while summarizing the assistant/tool + context immediately before it, which made the post-compression turn feel + like a fresh conversation. + """ + c = budget_compressor + c.tail_token_budget = 10 + c.protect_last_n = 20 + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "old start"}, + {"role": "assistant", "content": "old ack"}, + {"role": "user", "content": "middle work"}, + {"role": "assistant", "content": "middle ack"}, + {"role": "user", "content": "middle ask 2"}, + {"role": "assistant", "content": "middle answer 2"}, + {"role": "user", "content": "middle ask 3"}, + {"role": "assistant", "content": "middle answer 3"}, + {"role": "user", "content": "recent ask 1"}, + {"role": "assistant", "content": "recent answer 1"}, + {"role": "user", "content": "recent ask 2"}, + {"role": "assistant", "content": "recent answer 2"}, + {"role": "user", "content": "latest ask"}, + ] + + cut = c._find_tail_cut_by_tokens(messages, head_end=1) + + assert len(messages) - cut >= 8 + assert messages[cut]["content"] == "middle answer 2" + assert messages[-1]["content"] == "latest ask" + def test_soft_ceiling_allows_oversized_message(self, budget_compressor): """The 1.5x soft ceiling allows an oversized message to be included rather than splitting it."""