fix: recalculate safe_out from current input on each output-cap retry (#55546)

The retry loop computed safe_out from the error's available_tokens,
which reflected 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 stale budget meant every retry
still exceeded the context ceiling by 1+ tokens, burning through the
3-attempt limit.

Compute safe_out from estimate_messages_tokens_rough(messages) so
the cap tracks the growing input on each retry attempt.
fix/verification-admin-route-recovery
dmabry 2026-07-13 09:45:24 -05:00 committed by kshitij
parent 2d71e2f1e4
commit 62ea800586
2 changed files with 91 additions and 2 deletions

View File

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

View File

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