diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 75c07540d..6487f0509 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3475,12 +3475,22 @@ def run_conversation( # Error is purely about the output cap being too large. # Cap output to the available space and retry without # touching context_length or triggering compression. - safe_out = max(1, available_out - 64) # small safety margin + # + # The server's error reports available_tokens for the + # *previous* request. Between retries the agent appends + # tool results and error text, so the real input token + # count grows. Deriving safe_out from the error's + # available_tokens would keep reusing a stale budget and + # every retry would still exceed the ceiling by 1+ tokens. + # Compute safe_out from the *current* message token estimate + # so the cap tracks the growing input (#55546). + _current_input = estimate_messages_tokens_rough(messages) + safe_out = max(1, old_ctx - _current_input - 64) # small safety margin agent._ephemeral_max_output_tokens = safe_out agent._buffer_vprint( f"⚠️ Output cap too large for current prompt — " f"retrying with max_tokens={safe_out:,} " - f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})" + f"(current_input={_current_input:,}; context_length unchanged at {old_ctx:,})" ) # Still count against compression_attempts so we don't # loop forever if the error keeps recurring. diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py index 63c965ac9..9482d160a 100644 --- a/tests/test_ctx_halving_fix.py +++ b/tests/test_ctx_halving_fix.py @@ -360,3 +360,82 @@ class TestContextNotHalvedOnOutputCapError: available_out = parse_available_output_tokens_from_error(error_msg) safe_out = max(1, available_out - 64) assert safe_out == 1 + + +# --------------------------------------------------------------------------- +# Regression: safe_out must track growing input, not stale error budget +# --------------------------------------------------------------------------- + +class TestSafeOutTracksGrowingInput: + """Regression test for #55546: safe_out derived from stale available_tokens + reuses a budget computed from the *previous* request. Between retries the + agent appends tool results and error text, so the real input token count + grows. The fix computes safe_out from the *current* message token estimate + so the cap stays valid on every retry. + """ + + def _make_agent(self, context_length=262_144): + from run_agent import AIAgent + from agent.context_compressor import ContextCompressor + + agent = object.__new__(AIAgent) + agent.api_mode = "chat_completions" + agent.model = "qwen3.6-27b" + agent.base_url = "http://localhost:1234/v1" + agent.tools = [] + agent.max_tokens = 65_536 + agent.reasoning_config = None + agent._ephemeral_max_output_tokens = None + + compressor = MagicMock(spec=ContextCompressor) + compressor.context_length = context_length + agent.context_compressor = compressor + + agent._prepare_messages_for_api = MagicMock( + return_value=[{"role": "user", "content": "hi"}] + ) + agent._vprint = MagicMock() + agent.request_overrides = {} + return agent + + def test_safe_out_uses_current_input_not_stale_available(self): + """safe_out is computed from the current message token estimate, not + the stale available_tokens from the error message. + """ + from agent.model_metadata import estimate_messages_tokens_rough + + agent = self._make_agent(context_length=262_144) + old_ctx = agent.context_compressor.context_length + + # Simulate a conversation that's near the ceiling. + messages = [{"role": "user", "content": "x" * 800_000}] + _current_input = estimate_messages_tokens_rough(messages) + + # The fix: derive safe_out from the current input estimate. + safe_out = max(1, old_ctx - _current_input - 64) + agent._ephemeral_max_output_tokens = safe_out + + # Verify: safe_out is based on current input, not a stale error value. + assert agent._ephemeral_max_output_tokens == safe_out + assert agent.context_compressor.context_length == old_ctx + + def test_safe_out_tracks_growing_input(self): + """When messages grow between retries, safe_out shrinks accordingly.""" + from agent.model_metadata import estimate_messages_tokens_rough + + agent = self._make_agent(context_length=262_144) + old_ctx = agent.context_compressor.context_length + + # Initial messages. + messages = [{"role": "user", "content": "x" * 800_000}] + input_1 = estimate_messages_tokens_rough(messages) + safe_out_1 = max(1, old_ctx - input_1 - 64) + + # Simulate the agent appending a tool result (input grows). + messages.append({"role": "assistant", "content": "tool result" * 200}) + input_2 = estimate_messages_tokens_rough(messages) + safe_out_2 = max(1, old_ctx - input_2 - 64) + + # safe_out_2 must be <= safe_out_1 (shrinks as input grows). + assert safe_out_2 <= safe_out_1 + assert safe_out_2 >= 1