diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index d5aa05f58..56482f75b 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -669,14 +669,25 @@ class AgentRunner: else: coro = self.provider.chat_with_retry(**kwargs) + # Streaming requests already have provider-level idle timeouts + # (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock + # LLM timeout here, or healthy long reasoning streams can be killed just + # because total elapsed time exceeded NANOBOT_LLM_TIMEOUT_S. + outer_timeout_s = None if (wants_streaming or wants_progress_streaming) else timeout_s try: response = ( - await coro if timeout_s is None - else await asyncio.wait_for(coro, timeout=timeout_s) + await coro if outer_timeout_s is None + else await asyncio.wait_for(coro, timeout=outer_timeout_s) ) except asyncio.TimeoutError: + if outer_timeout_s is None: + return LLMResponse( + content="Error calling LLM: stream stalled", + finish_reason="error", + error_kind="timeout", + ) return LLMResponse( - content=f"Error calling LLM: timed out after {timeout_s:g}s", + content=f"Error calling LLM: timed out after {outer_timeout_s:g}s", finish_reason="error", error_kind="timeout", ) diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index dd28fa1cc..7e2d541ed 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -133,6 +133,50 @@ async def test_runner_times_out_hung_llm_request(): assert "timed out" in (result.final_content or "").lower() +@pytest.mark.asyncio +async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): + from nanobot.agent.hook import AgentHook, AgentHookContext + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock(spec=LLMProvider) + streamed: list[str] = [] + + async def chat_stream_with_retry(*, on_content_delta, **kwargs): + await asyncio.sleep(0.08) + await on_content_delta("still ") + await asyncio.sleep(0.08) + await on_content_delta("alive") + return LLMResponse(content="still alive", tool_calls=[]) + + 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) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "think for a while"}], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + hook=StreamingHook(), + llm_timeout_s=0.01, + )) + + assert result.stop_reason == "completed" + assert result.final_content == "still alive" + assert streamed == ["still ", "alive"] + 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 AgentRunSpec, AgentRunner