From 30ca0e107c0a583f0f688b03d9164a54e58a321e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:12:16 +0530 Subject: [PATCH] fix(agent): quantize eviction by the fit window so heavy tool results hold the frontier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor cut-back (`step > total - floor` -> `step = total - floor`) fires whenever an eight-carrier batch is wider than the fit window: tool results carrying several images, or user uploads filling most of the ceiling. `total - floor` tracks the total, so the retire count stopped being a step function and the frontier moved on every turn again — the per-image prefix rewrite this policy exists to prevent. Replay over 24 turns (prefix collapses; issue #113517 script): carrier shape before after 1 image / tool_result 2 2 2 images / tool_result 3 3 3 images / tool_result 13 7 4 images / tool_result 16 11 15 uploads + 1 / tool_result 15 10 The quantum is now `min(batch, window - floor)` where `window` is how many newest carriers fit. Each advance keeps at least `floor + 1` frames, so the cut-back clause is gone; holding for `window - floor` turns per advance is the most the floor allows. The one-image case (#113517's report) is unchanged: 21/29/37 -> 8/16/24 retired. Regression test: test_frontier_holds_when_tool_results_carry_several_images — red on the previous loop shape, green here. --- agent/image_eviction_policy.py | 17 ++++--- tests/agent/test_outbound_stale_vision.py | 54 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/agent/image_eviction_policy.py b/agent/image_eviction_policy.py index 0a1ff1cb9f..cde689d3d1 100644 --- a/agent/image_eviction_policy.py +++ b/agent/image_eviction_policy.py @@ -53,6 +53,13 @@ def outbound_image_retire_count( and a fixed one-batch retire stops enforcing the limit after the first batch. So the count advances in quanta until the request fits. + The quantum is ``batch`` capped at ``window - floor``, where ``window`` is how many newest + carriers fit under the ceiling. A quantum wider than that would step past the newest frames + on every advance; cutting each step back to exactly ``total - floor`` instead makes the + retire count track ``total`` again — the per-image frontier this policy exists to avoid, + visible whenever a tool result carries several images or uploads fill most of the ceiling. + Holding for ``window - floor`` turns per advance is the most the floor allows. + The floor is a SATISFIABILITY floor: it shelters the newest frames only when reserved uploads alone breach the block ceiling (no retirement can fix that), and never under byte pressure — the request-size limit is hard and the provider answers 413. @@ -78,14 +85,12 @@ def outbound_image_retire_count( max_retire = total - floor if not _fits(0) and _bytes_fit(floor) else total if max_retire <= 0: return 0 + window = max(k for k in range(total + 1) if _fits(k)) if _fits(0) else 0 + quantum = max(1, min(batch, window - floor)) + retire = 0 while retire < max_retire: - step = min(retire + batch, max_retire) - if step > total - floor and _fits(floor): - # A whole batch would retire the frames the model was just asked about while - # keeping the floor already clears the ceiling; take the smaller edit instead. - step = total - floor - retire = step + retire = min(retire + quantum, max_retire) if _fits(total - retire): break return retire diff --git a/tests/agent/test_outbound_stale_vision.py b/tests/agent/test_outbound_stale_vision.py index b940703fe1..e87f4a0585 100644 --- a/tests/agent/test_outbound_stale_vision.py +++ b/tests/agent/test_outbound_stale_vision.py @@ -150,6 +150,60 @@ class TestOutboundStaleVisionEviction: "each move rewrites a cached row and restarts the prefix" ) + def test_frontier_holds_when_tool_results_carry_several_images(self): + """Heavy carriers must still advance the frontier in steps, never per image. + + With three images per tool result, an eight-carrier batch is wider than the + fit window, so a step cut back to exactly ``total - floor`` tracks the total + and rewrites a cached row on every turn. The quantum must shrink to the window + instead: the frontier may move at most once per (window - floor) turns. + """ + from agent.conversation_loop import _clone_message_for_send + + def surviving(n: int) -> list: + history: list[dict] = [{"role": "user", "content": "start"}] + for i in range(n): + history.append( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": f"call_{i}", + "type": "function", + "function": {"name": "vision_analyze", "arguments": "{}"}, + } + ], + } + ) + history.append( + { + "role": "tool", + "tool_call_id": f"call_{i}", + "content": [ + {"type": "text", "text": f"shot {i}"}, + *[ + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,A{i}{k}"}} + for k in range(3) + ], + ], + } + ) + msgs = [_clone_message_for_send(m) for m in history] + evict_stale_outbound_tool_images(msgs) + assert _outbound_image_blocks(msgs) <= OUTBOUND_IMAGE_LIMIT + return _image_bearing_tool_ids(msgs) + + window = OUTBOUND_IMAGE_LIMIT // 3 + span = range(window + 1, window + 1 + 4 * (window - OUTBOUND_IMAGE_FLOOR)) + frontier = [surviving(n)[0] for n in span] + moves = sum(a != b for a, b in zip(frontier, frontier[1:])) + assert moves <= len(span) // (window - OUTBOUND_IMAGE_FLOOR), ( + f"frontier moved {moves} times over {len(span)} turns (frontier={frontier}); " + "a per-image frontier rewrites the cached prefix every turn" + ) + assert all(len(k) >= OUTBOUND_IMAGE_FLOOR for k in (surviving(n) for n in span)) + def test_multi_image_tool_results_count_as_blocks(self): """The provider limit counts image BLOCKS, not tool messages.