From 4916fc07ab08f3c2b16c585bd333d5f1f6b24ee6 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 14 Jul 2026 15:31:12 +0800 Subject: [PATCH] fix(agent): close reasoning on stream timeout --- docs/configuration.md | 2 +- nanobot/agent/runner.py | 13 ++++---- tests/agent/test_runner_core.py | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 004f6938..a23d9c1f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,7 +187,7 @@ These variables are process-level switches. Set them in the same terminal, servi | Variable | Default | Description | |----------|---------|-------------| | `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. | -| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds, around ordinary LLM requests. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. | +| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. | | `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. | | `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. | | `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` | unset | Marks that an external workspace sandbox is already enforced. Truthy values (`1`, `true`, `yes`, `on`, `enabled`) use `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` as the label; any other non-false value is treated as the provider name. | diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 3c37c176..0bc32938 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -824,16 +824,17 @@ class AgentRunner: ) except asyncio.TimeoutError: if outer_timeout_s is None: - return LLMResponse( + response = LLMResponse( content="Error calling LLM: stream stalled", finish_reason="error", error_kind="timeout", ) - return LLMResponse( - content=f"Error calling LLM: timed out after {outer_timeout_s:g}s", - finish_reason="error", - error_kind="timeout", - ) + else: + response = LLMResponse( + content=f"Error calling LLM: timed out after {outer_timeout_s:g}s", + finish_reason="error", + error_kind="timeout", + ) if progress_state and progress_state.get("reasoning_open"): await hook.emit_reasoning_end() dropped, all_dropped, original_finish_reason = ( diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index 1e3983f8..39b74725 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -280,6 +280,65 @@ async def test_runner_times_out_never_ending_streaming_request(): provider.chat_with_retry.assert_not_awaited() +@pytest.mark.asyncio +async def test_runner_closes_progress_reasoning_on_streaming_wall_timeout(): + from nanobot.agent.hook import AgentHook + from nanobot.agent.runner import AgentRunner + + provider = MagicMock(spec=LLMProvider) + provider.supports_progress_deltas = True + events: list[tuple[str, str | None]] = [] + + async def chat_stream_with_retry(*, on_content_delta, **kwargs): + try: + await on_content_delta("working...") + await asyncio.sleep(3600) + finally: + events.append(("provider_cancelled", None)) + + provider.chat_stream_with_retry = chat_stream_with_retry + provider.chat_with_retry = AsyncMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + + class ProgressReasoningHook(AgentHook): + async def emit_reasoning(self, reasoning_content: str | None) -> None: + if reasoning_content: + events.append(("reasoning", reasoning_content)) + + async def emit_reasoning_end(self) -> None: + events.append(("reasoning_end", None)) + + real_wait_for = asyncio.wait_for + + async def fake_wait_for(coro, *, timeout): + assert timeout == 300.0 + return await real_wait_for(coro, timeout=0.01) + + runner = AgentRunner() + with patch("nanobot.agent.runner.asyncio.wait_for", fake_wait_for): + result = await runner.run(make_run_spec(provider, + initial_messages=[{"role": "user", "content": "think forever"}], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + hook=ProgressReasoningHook(), + progress_callback=AsyncMock(), + stream_progress_deltas=True, + llm_timeout_s=1, + )) + + assert result.stop_reason == "error" + assert result.final_content == "Error calling LLM: timed out after 300s" + assert events == [ + ("reasoning", "working..."), + ("provider_cancelled", None), + ("reasoning_end", None), + ] + provider.chat_with_retry.assert_not_awaited() + + @pytest.mark.asyncio async def test_runner_replaces_empty_tool_result_with_marker(): from nanobot.agent.runner import AgentRunner