From 511c764f4550fd7390688ecffd098f80ee16381b Mon Sep 17 00:00:00 2001 From: Solaris-star <820622658@qq.com> Date: Tue, 28 Jul 2026 16:18:03 +0800 Subject: [PATCH] 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 --- nanobot/agent/runner.py | 4 +-- tests/agent/test_runner_errors.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 3c5f88c36..6c91a6aef 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -575,7 +575,7 @@ class AgentRunner: ) 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 if empty_content_retries < _MAX_EMPTY_RETRIES: logger.warning( @@ -608,7 +608,7 @@ class AgentRunner: original_content = 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: length_recovery_parts.append( _restore_outer_whitespace(clean or "", original_content) diff --git a/tests/agent/test_runner_errors.py b/tests/agent/test_runner_errors.py index a9c426212..6125fb06b 100644 --- a/tests/agent/test_runner_errors.py +++ b/tests/agent/test_runner_errors.py @@ -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" ] 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"