diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 4f252f47..3c37c176 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -806,11 +806,17 @@ class AgentRunner: else: coro = spec.runtime.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 + # Streaming requests also have provider-level idle timeouts + # (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing + # very slow deltas can still run forever. Use a more generous wall-clock + # timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an + # opt-out for all LLM wall-clock timeouts. + is_streaming_request = wants_streaming or wants_progress_streaming + outer_timeout_s = ( + max(300.0, timeout_s * 2) + if is_streaming_request and timeout_s is not None + else timeout_s + ) try: response = ( await coro if outer_timeout_s is None diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index 752c3bd1..1e3983f8 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -189,7 +189,7 @@ async def test_runner_times_out_hung_llm_request(): @pytest.mark.asyncio -async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): +async def test_runner_applies_outer_wall_timeout_to_streaming_requests(): from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.runner import AgentRunner @@ -216,8 +216,13 @@ async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): streamed.append(delta) runner = AgentRunner() - wait_for = AsyncMock(side_effect=AssertionError("streaming path must not use wait_for")) - with patch("nanobot.agent.runner.asyncio.wait_for", wait_for): + wait_for_calls: list[float] = [] + + async def fake_wait_for(coro, *, timeout): + wait_for_calls.append(timeout) + return await coro + + 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 for a while"}], tools=tools, @@ -232,7 +237,47 @@ async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): assert result.final_content == "still alive" assert streamed == ["still ", "alive"] provider.chat_with_retry.assert_not_awaited() - wait_for.assert_not_awaited() + assert wait_for_calls == [300.0] + + +@pytest.mark.asyncio +async def test_runner_times_out_never_ending_streaming_request(): + from nanobot.agent.hook import AgentHook + from nanobot.agent.runner import AgentRunner + + provider = MagicMock(spec=LLMProvider) + + async def chat_stream_with_retry(*, on_content_delta, **kwargs): + await asyncio.sleep(3600) + + 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 fake_wait_for(coro, *, timeout): + coro.close() + raise asyncio.TimeoutError + + 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=StreamingHook(), + llm_timeout_s=200, + )) + + assert result.stop_reason == "error" + assert result.final_content == "Error calling LLM: timed out after 400s" + provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio