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
+31 -1
View File
@@ -60,6 +60,18 @@ _MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3 _MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5 _MAX_INJECTION_CYCLES = 5
def _restore_outer_whitespace(content: str, original: str | None) -> str:
"""Restore boundary whitespace stripped while cleaning one recovered segment."""
if not original:
return content
leading_size = len(original) - len(original.lstrip())
trailing_size = len(original) - len(original.rstrip())
leading = original[:leading_size]
trailing = original[-trailing_size:] if trailing_size else ""
return f"{leading}{content}{trailing}"
@dataclass(slots=True) @dataclass(slots=True)
class AgentRunSpec: class AgentRunSpec:
"""Configuration for a single agent execution.""" """Configuration for a single agent execution."""
@@ -381,6 +393,9 @@ class AgentRunner:
workspace_violation_counts: dict[str, int] = {} workspace_violation_counts: dict[str, int] = {}
empty_content_retries = 0 empty_content_retries = 0
length_recovery_count = 0 length_recovery_count = 0
# Segments from one uninterrupted length-recovery chain. Tool work or
# injected user input starts a new logical answer and clears the chain.
length_recovery_parts: list[str] = []
had_injections = False had_injections = False
injection_cycles = 0 injection_cycles = 0
compacted_tool_call_ids: set[str] = set() compacted_tool_call_ids: set[str] = set()
@@ -418,6 +433,7 @@ class AgentRunner:
context.response = response context.response = response
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
original_content = response.content
reasoning_text, cleaned_content = extract_reasoning( reasoning_text, cleaned_content = extract_reasoning(
response.reasoning_content, response.reasoning_content,
response.thinking_blocks, response.thinking_blocks,
@@ -519,6 +535,7 @@ class AgentRunner:
) )
empty_content_retries = 0 empty_content_retries = 0
length_recovery_count = 0 length_recovery_count = 0
length_recovery_parts.clear()
# Checkpoint 1: drain injections after tools, before next LLM call # Checkpoint 1: drain injections after tools, before next LLM call
_drained, injection_cycles = await self._try_drain_injections( _drained, injection_cycles = await self._try_drain_injections(
spec, messages, None, injection_cycles, spec, messages, None, injection_cycles,
@@ -567,11 +584,15 @@ class AgentRunner:
context.response = response context.response = response
context.usage = dict(raw_usage) context.usage = dict(raw_usage)
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
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" and not is_blank_text(clean):
length_recovery_count += 1 length_recovery_count += 1
if length_recovery_count <= _MAX_LENGTH_RECOVERIES: if length_recovery_count <= _MAX_LENGTH_RECOVERIES:
length_recovery_parts.append(
_restore_outer_whitespace(clean, original_content)
)
logger.info( logger.info(
"Output truncated on turn {} for {} ({}/{}); continuing", "Output truncated on turn {} for {} ({}/{}); continuing",
iteration, iteration,
@@ -614,6 +635,7 @@ class AgentRunner:
await hook.on_stream_end(context, resuming=should_continue) await hook.on_stream_end(context, resuming=should_continue)
if should_continue: if should_continue:
length_recovery_parts.clear()
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
@@ -635,6 +657,7 @@ class AgentRunner:
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
length_recovery_parts.clear()
continue continue
break break
if is_blank_text(clean): if is_blank_text(clean):
@@ -652,6 +675,7 @@ class AgentRunner:
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
length_recovery_parts.clear()
continue continue
break break
@@ -671,7 +695,13 @@ class AgentRunner:
"pending_tool_calls": [], "pending_tool_calls": [],
}, },
) )
final_content = clean if length_recovery_parts:
final_content = (
"".join(length_recovery_parts)
+ _restore_outer_whitespace(clean, original_content)
).strip()
else:
final_content = clean
context.final_content = final_content context.final_content = final_content
context.stop_reason = stop_reason context.stop_reason = stop_reason
await hook.after_iteration(context) await hook.after_iteration(context)
+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" 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 @pytest.mark.asyncio
async def test_runner_empty_response_does_not_break_tool_chain(): async def test_runner_empty_response_does_not_break_tool_chain():
"""An empty intermediate response must not kill an ongoing 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() 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 @pytest.mark.asyncio
async def test_runner_passes_cached_tokens_to_hook_context(): async def test_runner_passes_cached_tokens_to_hook_context():
"""Hook context.usage should contain cached_tokens.""" """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 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 @pytest.mark.asyncio
async def test_checkpoint2_preserves_final_response_in_history_before_followup(): 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.""" """A follow-up injected after a final answer must still see that answer in history."""