mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(agent): route finish_reason='length' with blank content to length recovery
When an LLM response arrives with finish_reason='length' and has_tool_calls but blank text content (e.g. the model spent its whole output budget on a tool call whose closing tag was truncated), the runner dropped the tool calls and then misrouted the blank response into the empty-response retry branch. Retrying the same prompt cannot recover from output-budget exhaustion, so every retry hit the same length ceiling and the turn ended in the generic apology. The length-recovery branch was gated on 'finish_reason == length and not is_blank_text(clean)', so a blank-but-truncated turn could never reach it. - The empty-response retry branch now excludes finish_reason == 'length' (in addition to 'error'). - The length-recovery branch no longer requires non-blank content, so a blank-but-truncated turn enters recovery and appends build_length_recovery_message (which handles a blank tail safely). Adds a regression test asserting the length-recovery path is taken; it fails on the unfixed code and passes with the fix. Fixes #5133
This commit is contained in:
parent
0eac82984c
commit
511c764f45
@ -575,7 +575,7 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
clean = hook.finalize_content(context, response.content)
|
clean = hook.finalize_content(context, response.content)
|
||||||
if response.finish_reason != "error" and is_blank_text(clean):
|
if response.finish_reason not in ("error", "length") and is_blank_text(clean):
|
||||||
empty_content_retries += 1
|
empty_content_retries += 1
|
||||||
if empty_content_retries < _MAX_EMPTY_RETRIES:
|
if empty_content_retries < _MAX_EMPTY_RETRIES:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -608,7 +608,7 @@ class AgentRunner:
|
|||||||
original_content = response.content
|
original_content = response.content
|
||||||
clean = hook.finalize_content(context, response.content)
|
clean = hook.finalize_content(context, response.content)
|
||||||
|
|
||||||
if response.finish_reason == "length" and not is_blank_text(clean):
|
if response.finish_reason == "length":
|
||||||
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
|
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
|
||||||
length_recovery_parts.append(
|
length_recovery_parts.append(
|
||||||
_restore_outer_whitespace(clean or "", original_content)
|
_restore_outer_whitespace(clean or "", original_content)
|
||||||
|
|||||||
@ -310,3 +310,50 @@ async def test_runner_tool_error_preserves_tool_results_in_messages():
|
|||||||
i for i, m in enumerate(result.messages) if m.get("role") == "tool"
|
i for i, m in enumerate(result.messages) if m.get("role") == "tool"
|
||||||
]
|
]
|
||||||
assert all(ti > asst_tc_idx for ti in tool_indices)
|
assert all(ti > asst_tc_idx for ti in tool_indices)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_length_finish_with_blank_content_routes_to_length_recovery():
|
||||||
|
"""Regression test for #5133.
|
||||||
|
|
||||||
|
A response with finish_reason='length' and blank content (e.g. the model
|
||||||
|
spent its whole output budget on a tool call whose closing tag was
|
||||||
|
truncated) must take the length-recovery path, not the empty-response
|
||||||
|
retry path. Retrying the same prompt cannot recover from output-budget
|
||||||
|
exhaustion.
|
||||||
|
"""
|
||||||
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
from nanobot.utils.runtime import LENGTH_RECOVERY_PROMPT
|
||||||
|
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
# First call: truncated (length) with blank content and a dropped tool call.
|
||||||
|
# Second call: normal completion so the loop can terminate.
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||||
|
LLMResponse(
|
||||||
|
content="",
|
||||||
|
finish_reason="length",
|
||||||
|
tool_calls=[ToolCallRequest(id="call_1", name="exec", arguments={})],
|
||||||
|
usage={},
|
||||||
|
),
|
||||||
|
LLMResponse(content="done", finish_reason="stop", tool_calls=[], usage={}),
|
||||||
|
])
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
|
runner = AgentRunner()
|
||||||
|
result = await runner.run(make_run_spec(provider,
|
||||||
|
initial_messages=[{"role": "user", "content": "do a long task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=5,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
# The runner must have injected a length-recovery prompt and continued,
|
||||||
|
# rather than exhausting empty-response retries into a generic apology.
|
||||||
|
user_msgs = [m.get("content") or "" for m in result.messages if m.get("role") == "user"]
|
||||||
|
assert any(LENGTH_RECOVERY_PROMPT in c for c in user_msgs), (
|
||||||
|
"expected a length-recovery message to be appended for a "
|
||||||
|
"finish_reason='length' response with blank content"
|
||||||
|
)
|
||||||
|
assert result.final_content == "done"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user