fix(agent): preserve recent turns during compression

This commit is contained in:
konsisumer 2026-06-12 09:27:58 +02:00 committed by Teknium
parent bbf020e709
commit aec38855b5
2 changed files with 58 additions and 7 deletions

View file

@ -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

View file

@ -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."""