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
parent
2d71e2f1e4
commit
62ea800586
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue