fix(agent): preserve length-recovered output

This commit is contained in:
chengyongru
2026-07-27 01:39:46 +08:00
committed by Xubin Ren
parent c1899e2cb4
commit b19039f9d0
4 changed files with 180 additions and 1 deletions
+64
View File
@@ -450,6 +450,70 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry():
assert result.stop_reason == "empty_final_response"
@pytest.mark.asyncio
async def test_runner_length_recovery_returns_all_segments():
"""Recovered output segments are returned together instead of only the tail."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="first ", finish_reason="length"),
LLMResponse(content="second ", finish_reason="length"),
LLMResponse(content="third", finish_reason="stop"),
])
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "give a long answer"}],
tools=tools,
model="test-model",
max_iterations=5,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert result.final_content == "first second third"
assert [
message["content"]
for message in result.messages
if message.get("role") == "assistant"
] == ["first", "second", "third"]
assert provider.chat_with_retry.await_count == 3
@pytest.mark.asyncio
async def test_runner_length_recovery_does_not_leak_across_tool_calls():
"""A recovered prefix belongs only to its contiguous response chain."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="working", finish_reason="length"),
LLMResponse(
content=None,
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "x"})],
finish_reason="tool_calls",
),
LLMResponse(content="final answer", finish_reason="stop"),
])
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="file content")
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "inspect a file"}],
tools=tools,
model="test-model",
max_iterations=5,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert result.final_content == "final answer"
assert result.tools_used == ["read_file"]
@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.
+49
View File
@@ -143,6 +143,55 @@ async def test_runner_streaming_hook_receives_deltas_and_end_signal():
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_length_recovery_streams_segments_once_and_returns_all_content():
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
streamed: list[str] = []
endings: list[bool] = []
responses = iter([
LLMResponse(content="first ", finish_reason="length"),
LLMResponse(content="second", finish_reason="stop"),
])
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
response = next(responses)
await on_content_delta(response.content or "")
return response
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
class StreamingHook(AgentHook):
def wants_streaming(self) -> bool:
return True
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
streamed.append(delta)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
endings.append(resuming)
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "give a long answer"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=StreamingHook(),
))
assert result.final_content == "first second"
assert streamed == ["first ", "second"]
assert endings == [True, False]
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_passes_cached_tokens_to_hook_context():
"""Hook context.usage should contain cached_tokens."""
+36
View File
@@ -352,6 +352,42 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
assert stream_end_calls[-1] is False
@pytest.mark.asyncio
async def test_injected_followup_starts_new_length_recovery_chain():
"""Recovered content from the prior answer must not prefix a follow-up reply."""
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="first", finish_reason="length"),
LLMResponse(content="second", finish_reason="stop"),
LLMResponse(content="follow-up answer", finish_reason="stop"),
])
tools = MagicMock()
tools.get_definitions.return_value = []
injection_queue = asyncio.Queue()
inject_cb = _make_injection_callback(injection_queue)
await injection_queue.put(
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
)
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "give a long answer"}],
tools=tools,
model="test-model",
max_iterations=5,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
injection_callback=inject_cb,
))
assert result.had_injections is True
assert result.final_content == "follow-up answer"
assert provider.chat_with_retry.await_count == 3
@pytest.mark.asyncio
async def test_checkpoint2_preserves_final_response_in_history_before_followup():
"""A follow-up injected after a final answer must still see that answer in history."""