fix(runner): silent retry on empty response before finalization

This commit is contained in:
Xubin Ren 2026-04-07 06:58:54 +00:00 committed by Xubin Ren
parent 0355f20919
commit 02597c3ec9
3 changed files with 91 additions and 11 deletions

View File

@ -30,6 +30,7 @@ from nanobot.utils.runtime import (
) )
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
_MAX_EMPTY_RETRIES = 2
_SNIP_SAFETY_BUFFER = 1024 _SNIP_SAFETY_BUFFER = 1024
@dataclass(slots=True) @dataclass(slots=True)
class AgentRunSpec: class AgentRunSpec:
@ -86,6 +87,7 @@ class AgentRunner:
stop_reason = "completed" stop_reason = "completed"
tool_events: list[dict[str, str]] = [] tool_events: list[dict[str, str]] = []
external_lookup_counts: dict[str, int] = {} external_lookup_counts: dict[str, int] = {}
empty_content_retries = 0
for iteration in range(spec.max_iterations): for iteration in range(spec.max_iterations):
try: try:
@ -178,15 +180,30 @@ class AgentRunner:
"pending_tool_calls": [], "pending_tool_calls": [],
}, },
) )
empty_content_retries = 0
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
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 != "error" and is_blank_text(clean):
empty_content_retries += 1
if empty_content_retries < _MAX_EMPTY_RETRIES:
logger.warning(
"Empty response on turn {} for {} ({}/{}); retrying",
iteration,
spec.session_key or "default",
empty_content_retries,
_MAX_EMPTY_RETRIES,
)
if hook.wants_streaming():
await hook.on_stream_end(context, resuming=False)
await hook.after_iteration(context)
continue
logger.warning( logger.warning(
"Empty final response on turn {} for {}; retrying with explicit finalization prompt", "Empty response on turn {} for {} after {} retries; attempting finalization",
iteration, iteration,
spec.session_key or "default", spec.session_key or "default",
empty_content_retries,
) )
if hook.wants_streaming(): if hook.wants_streaming():
await hook.on_stream_end(context, resuming=False) await hook.on_stream_end(context, resuming=False)

View File

@ -16,8 +16,7 @@ EMPTY_FINAL_RESPONSE_MESSAGE = (
) )
FINALIZATION_RETRY_PROMPT = ( FINALIZATION_RETRY_PROMPT = (
"You have already finished the tool work. Do not call any more tools. " "Please provide your response to the user based on the conversation above."
"Using only the conversation and tool results above, provide the final answer for the user now."
) )

View File

@ -458,6 +458,7 @@ async def test_runner_uses_raw_messages_when_context_governance_fails():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_retries_empty_final_response_with_summary_prompt(): async def test_runner_retries_empty_final_response_with_summary_prompt():
"""Empty responses get 2 silent retries before finalization kicks in."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
@ -465,11 +466,11 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
async def chat_with_retry(*, messages, tools=None, **kwargs): async def chat_with_retry(*, messages, tools=None, **kwargs):
calls.append({"messages": messages, "tools": tools}) calls.append({"messages": messages, "tools": tools})
if len(calls) == 1: if len(calls) <= 2:
return LLMResponse( return LLMResponse(
content=None, content=None,
tool_calls=[], tool_calls=[],
usage={"prompt_tokens": 10, "completion_tokens": 1}, usage={"prompt_tokens": 5, "completion_tokens": 1},
) )
return LLMResponse( return LLMResponse(
content="final answer", content="final answer",
@ -486,20 +487,23 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
max_iterations=1, max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
)) ))
assert result.final_content == "final answer" assert result.final_content == "final answer"
assert len(calls) == 2 # 2 silent retries (iterations 0,1) + finalization on iteration 1
assert calls[1]["tools"] is None assert len(calls) == 3
assert "Do not call any more tools" in calls[1]["messages"][-1]["content"] assert calls[0]["tools"] is not None
assert calls[1]["tools"] is not None
assert calls[2]["tools"] is None
assert result.usage["prompt_tokens"] == 13 assert result.usage["prompt_tokens"] == 13
assert result.usage["completion_tokens"] == 8 assert result.usage["completion_tokens"] == 9
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_uses_specific_message_after_empty_finalization_retry(): async def test_runner_uses_specific_message_after_empty_finalization_retry():
"""After silent retries + finalization all return empty, stop_reason is empty_final_response."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
@ -517,7 +521,7 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry():
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
max_iterations=1, max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
)) ))
@ -525,6 +529,66 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry():
assert result.stop_reason == "empty_final_response" assert result.stop_reason == "empty_final_response"
@pytest.mark.asyncio
async def test_runner_empty_response_does_not_break_tool_chain():
"""An empty intermediate response must not kill an ongoing tool chain.
Sequence: tool_call empty tool_call final text.
The runner should recover via silent retry and complete normally.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
call_count = 0
async def chat_with_retry(*, messages, tools=None, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return LLMResponse(
content=None,
tool_calls=[ToolCallRequest(id="tc1", name="read_file", arguments={"path": "a.txt"})],
usage={"prompt_tokens": 10, "completion_tokens": 5},
)
if call_count == 2:
return LLMResponse(content=None, tool_calls=[], usage={"prompt_tokens": 10, "completion_tokens": 1})
if call_count == 3:
return LLMResponse(
content=None,
tool_calls=[ToolCallRequest(id="tc2", name="read_file", arguments={"path": "b.txt"})],
usage={"prompt_tokens": 10, "completion_tokens": 5},
)
return LLMResponse(
content="Here are the results.",
tool_calls=[],
usage={"prompt_tokens": 10, "completion_tokens": 10},
)
provider.chat_with_retry = chat_with_retry
provider.chat_stream_with_retry = chat_with_retry
async def fake_tool(name, args, **kw):
return "file content"
tool_registry = MagicMock()
tool_registry.get_definitions.return_value = [{"type": "function", "function": {"name": "read_file"}}]
tool_registry.execute = AsyncMock(side_effect=fake_tool)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "read both files"}],
tools=tool_registry,
model="test-model",
max_iterations=10,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert result.final_content == "Here are the results."
assert result.stop_reason == "completed"
assert call_count == 4
assert "read_file" in result.tools_used
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch): def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunSpec, AgentRunner