From 041b489d566bfb1d6816d53d9acd5ac50b8d5af0 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:53:46 +0200 Subject: [PATCH] fix(agent): refund per-turn compression budget after real progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marathon tool turn burned all compression_attempts on *successful* pre-API compactions; the gate then went permanently dark and the context grew unchecked until the provider rejected the request terminally ("Context length exceeded: max compression attempts (3) reached", session f087963205f9, 2026-08-01). The budget now refunds at loop top when the assembled request is back under threshold * 0.8 AND the compressor's own should_compress() agrees the pressure is gone. Anti-thrash intent of the cap is preserved (#11529): no-progress passes never reach the refund margin, divergent-signal cases (should_compress still True) keep the budget burnt, and the insufficient-progress blocker is untouched. Validation: new behavioral suite (7) + all 130 compression/context tests via scripts/run_tests_hermetic.py. Rückbau: Commit revertieren; kein Zustand, keine Migration. --- agent/conversation_loop.py | 52 ++++ .../test_compression_budget_refund.py | 235 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 tests/run_agent/test_compression_budget_refund.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 3ca96898cf997..a338f727b3a28 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -93,6 +93,33 @@ logger = logging.getLogger(__name__) # to treat it as cancellation metadata rather than assistant prose. INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response (" +# Refund the per-turn compression budget only when the assembled request has +# dropped this far below threshold_tokens. The gap between this margin and +# 1.0 is the anti-thrash belt: a compaction that lands barely under threshold +# keeps its burnt attempt, so borderline shrink/regrow cycles cannot recycle +# the budget indefinitely. +_COMPRESSION_BUDGET_REFUND_MARGIN = 0.8 + + +def _should_refund_compression_budget( + compression_attempts: int, + request_pressure_tokens: int, + threshold_tokens: int, +) -> bool: + """True when burnt compression attempts should be returned to the turn. + + A compaction that brought the assembled request comfortably back under + threshold made real progress, so it must not permanently consume the + per-turn overflow-recovery backstop. No-progress passes never get here + below the margin, so they still exhaust the budget (#11529). + """ + return bool( + compression_attempts + and threshold_tokens > 0 + and request_pressure_tokens + < threshold_tokens * _COMPRESSION_BUDGET_REFUND_MARGIN + ) + # Modules that indicate a deterministic local processing error when they # appear in an exception traceback WITHOUT any API-call module. Used by the # outer-loop error classifier to avoid retrying bugs that will fail @@ -1833,6 +1860,31 @@ def run_conversation( f"{_previous_preflight_pressure:,}", f"{request_pressure_tokens:,}", ) + # Refund the shared overflow-recovery budget once the assembled + # request is comfortably back under threshold: a compaction that got + # us here made real progress, so it must not permanently consume the + # per-turn backstop. Without this, a long tool-heavy turn burns all + # attempts on *successful* pre-API compactions, the gate below goes + # permanently dark, and the context grows unchecked until the + # provider rejects the request terminally (root cause of the + # max-compression-attempts dead end on marathon turns). The + # compressor's own should_compress() must agree the pressure is gone — + # when it and the local estimate disagree (#36718 noise, runaway- + # compaction stubs), the budget stays burnt and the hard per-turn cap + # keeps its original meaning. + if _should_refund_compression_budget( + compression_attempts, request_pressure_tokens, _preflight_threshold + ) and not _compressor.should_compress(request_pressure_tokens): + logger.info( + "Compression budget refunded: ~%s request tokens < %s%% of " + "%s threshold (attempts were %s/%s)", + f"{request_pressure_tokens:,}", + int(_COMPRESSION_BUDGET_REFUND_MARGIN * 100), + f"{_preflight_threshold:,}", + compression_attempts, + max_compression_attempts, + ) + compression_attempts = 0 _defer_preflight = getattr( _compressor, "should_defer_preflight_to_real_usage", lambda _t: False ) diff --git a/tests/run_agent/test_compression_budget_refund.py b/tests/run_agent/test_compression_budget_refund.py new file mode 100644 index 0000000000000..5de0878480312 --- /dev/null +++ b/tests/run_agent/test_compression_budget_refund.py @@ -0,0 +1,235 @@ +"""Behavioral tests for the per-turn compression budget refund. + +``compression_attempts`` is a shared per-turn backstop (pre-API gate, +overflow/413 handlers, post-tool gate). Before the refund fix, *successful* +pre-API compactions consumed it permanently: a marathon tool turn burned all +attempts on compactions that worked, the pre-API gate went dark for the rest +of the turn, and the context grew unchecked until the provider rejected the +request terminally ("max compression attempts (N) reached"). + +The refund returns the budget when BOTH hold at the top of a loop pass: + +* the assembled request sits below ``threshold_tokens * + _COMPRESSION_BUDGET_REFUND_MARGIN`` (real progress, not a borderline + shrink), and +* the compressor's own ``should_compress()`` agrees there is no pressure + (divergent-signal guard — a compressor that still demands compression + keeps the hard cap's original meaning, see + test_post_tool_compression_attempt_cap.py). + +These tests drive ``run_conversation()`` through real tool iterations — no +source inspection, only observable compaction counts. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from agent.conversation_loop import _should_refund_compression_budget +from run_agent import AIAgent + + +# --------------------------------------------------------------------------- +# Unit: refund decision +# --------------------------------------------------------------------------- + + +class TestRefundDecision: + def test_no_attempts_no_refund(self): + assert not _should_refund_compression_budget(0, 100, 10_000) + + def test_zero_threshold_no_refund(self): + assert not _should_refund_compression_budget(2, 100, 0) + + def test_barely_under_threshold_no_refund(self): + # 9,999 of 10,000 is inside the anti-thrash belt (margin 0.8). + assert not _should_refund_compression_budget(2, 9_999, 10_000) + + def test_at_margin_no_refund(self): + assert not _should_refund_compression_budget(2, 8_000, 10_000) + + def test_comfortably_under_margin_refunds(self): + assert _should_refund_compression_budget(2, 7_999, 10_000) + assert _should_refund_compression_budget(1, 100, 10_000) + + +# --------------------------------------------------------------------------- +# Behavioral: marathon tool turn keeps compacting past the old cap +# --------------------------------------------------------------------------- + + +def _tool_call(i: int): + return SimpleNamespace( + id=f"call_{i}", + type="function", + function=SimpleNamespace(name="web_search", arguments='{"query": "x"}'), + ) + + +def _tool_response(i: int): + msg = SimpleNamespace( + content=None, + reasoning_content=None, + reasoning=None, + tool_calls=[_tool_call(i)], + ) + choice = SimpleNamespace(message=msg, finish_reason="tool_calls") + return SimpleNamespace(choices=[choice], model="test/model", usage=None) + + +def _stop_response(): + msg = SimpleNamespace( + content="done", + reasoning_content=None, + reasoning=None, + tool_calls=None, + ) + choice = SimpleNamespace(message=msg, finish_reason="stop") + return SimpleNamespace(choices=[choice], model="test/model", usage=None) + + +def _make_tool_defs(*names: str) -> list: + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +THRESHOLD = 10_000 + +# Each tool result is large enough that the assembled request crosses +# THRESHOLD every iteration (estimator is ~chars/4), forcing one pre-API +# compaction per iteration — but stays below the 100K-char per-result +# persistence threshold (tools/budget_config.py) so it reaches the context +# untruncated. +BIG_TOOL_RESULT = "x" * 60_000 + + +def _coherent_compressor() -> MagicMock: + """A compressor whose should_compress() reflects the passed estimate. + + Unlike the always-True stub in the attempt-cap tests, this models the + real coupling: pressure at/over threshold → compress; pressure gone → + healthy. That coupling is what makes the refund safe. + """ + compressor = MagicMock() + compressor.protect_first_n = 3 + compressor.protect_last_n = 20 + compressor.threshold_tokens = THRESHOLD + compressor.context_length = 200_000 + compressor.last_prompt_tokens = 0 + compressor.should_compress.side_effect = lambda t=None: (t or 0) >= THRESHOLD + compressor.should_defer_preflight_to_real_usage.return_value = False + compressor.get_active_compression_failure_cooldown.return_value = None + return compressor + + +@pytest.fixture() +def agent(): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + max_iterations=20, + ) + a.client = MagicMock() + a._cached_system_prompt = "You are helpful." + a._use_prompt_caching = False + a._disable_streaming = True + a.tool_delay = 0 + a.save_trajectories = False + a.compression_enabled = True + a.context_compressor = _coherent_compressor() + return a + + +def _run_marathon_turn(agent, n_tool_iterations: int): + """Drive one turn of ``n_tool_iterations`` oversized tool results.""" + responses = [_tool_response(i) for i in range(n_tool_iterations)] + responses.append(_stop_response()) + agent.client.chat.completions.create.side_effect = responses + + compress_calls = [] + + def _fake_compress(messages, system_message, **_kwargs): + # Model a compaction that works: blank out every oversized payload, + # keeping roles and tool-call pairing intact so sanitization is + # unaffected. Pressure drops far below the refund margin. + compress_calls.append(len(messages)) + compacted = [ + dict(m, content="[summarized]") + if isinstance(m, dict) and len(str(m.get("content") or "")) > 5_000 + else m + for m in messages + ] + return compacted, "compressed prompt" + + with ( + patch.object(agent, "_compress_context", side_effect=_fake_compress), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch( + "run_agent.handle_function_call", + lambda name, args, task_id=None, **kwargs: json.dumps( + {"ok": True, "payload": BIG_TOOL_RESULT} + ), + ), + ): + result = agent.run_conversation("do a lot of tool work") + + return result, compress_calls + + +class TestCompressionBudgetRefund: + def test_marathon_turn_compacts_past_the_per_turn_cap(self, agent): + """8 oversized tool iterations → more compactions than the old cap. + + Pre-refund, the 4th+ pressure spike found the budget exhausted, the + pre-API gate stayed dark, and the request grew unchecked. With the + refund, every genuine pressure spike is compacted and the turn + completes. + """ + assert agent.max_compression_attempts == 3 # config default + result, compress_calls = _run_marathon_turn(agent, n_tool_iterations=8) + + assert result["completed"] is True + assert len(compress_calls) > 3, ( + "successful compactions must refund the per-turn budget; " + f"got only {len(compress_calls)} compactions for 8 pressure spikes" + ) + + def test_no_refund_when_compressor_still_reports_pressure(self, agent): + """Divergent signals: compressor demands compression regardless of + the local estimate → budget stays burnt at the hard cap. + + Mirrors the always-True stub of the attempt-cap regression tests — + the refund must not reopen that runaway.""" + agent.context_compressor.should_compress.side_effect = None + agent.context_compressor.should_compress.return_value = True + + result, compress_calls = _run_marathon_turn(agent, n_tool_iterations=8) + + assert result["completed"] is True + assert len(compress_calls) <= agent.max_compression_attempts, ( + "with should_compress pinned True the per-turn cap must hold; " + f"got {len(compress_calls)} compactions" + )