diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 380f6a792..f66f01409 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -922,18 +922,7 @@ class AgentRunner: conversation_state: ProviderConversationStateController, provider_context: ProviderCallContext | None = None, ) -> LLMResponse: - timeout_s: float | None = spec.llm_timeout_s - if timeout_s is None: - # Default to a finite timeout to avoid per-session lock starvation when an LLM - # request hangs indefinitely (e.g. gateway/network stall). - # Set NANOBOT_LLM_TIMEOUT_S=0 to disable. - raw = os.environ.get("NANOBOT_LLM_TIMEOUT_S", "300").strip() - try: - timeout_s = float(raw) - except (TypeError, ValueError): - timeout_s = 300.0 - if timeout_s <= 0: - timeout_s = None + timeout_s = self._resolve_llm_timeout_s(spec) kwargs = self._build_request_kwargs( spec, @@ -1302,10 +1291,38 @@ class AgentRunner: messages, tools=None, ) - return await spec.runtime.provider.chat_with_retry( + coro = spec.runtime.provider.chat_with_retry( **kwargs, provider_context=provider_context, ) + timeout_s = self._resolve_llm_timeout_s(spec) + try: + return ( + await coro + if timeout_s is None + else await asyncio.wait_for(coro, timeout=timeout_s) + ) + except asyncio.TimeoutError: + return LLMResponse( + content=f"Error calling LLM: timed out after {timeout_s:g}s", + finish_reason="error", + error_kind="timeout", + ) + + @staticmethod + def _resolve_llm_timeout_s(spec: AgentRunSpec) -> float | None: + """Resolve the wall-clock limit shared by every model request path.""" + timeout_s = spec.llm_timeout_s + if timeout_s is None: + # Default to a finite timeout to avoid per-session lock starvation when an LLM + # request hangs indefinitely (e.g. gateway/network stall). + # Set NANOBOT_LLM_TIMEOUT_S=0 to disable. + raw = os.environ.get("NANOBOT_LLM_TIMEOUT_S", "300").strip() + try: + timeout_s = float(raw) + except (TypeError, ValueError): + timeout_s = 300.0 + return timeout_s if timeout_s > 0 else None @staticmethod def _budget_exhausted_finalization_messages( diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index abbd2c55b..e1adc5d76 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -499,6 +499,55 @@ 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_times_out_hung_max_iteration_finalization(): + from nanobot.agent.runner import AgentRunner + + provider = MagicMock() + calls = 0 + + async def chat_with_retry(**kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return LLMResponse( + content="", + tool_calls=[ + ToolCallRequest( + id="call_1", + name="probe", + arguments={}, + ) + ], + finish_reason="tool_calls", + ) + await asyncio.Event().wait() + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="ok") + + result = await asyncio.wait_for( + AgentRunner().run(make_run_spec( + provider, + initial_messages=[{"role": "user", "content": "run the probe"}], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + max_iterations_message="fallback after {max_iterations} iteration", + llm_timeout_s=0.01, + )), + timeout=1.0, + ) + + assert calls == 2 + assert result.stop_reason == "max_iterations" + assert result.error is None + assert result.final_content == "fallback after 1 iteration" + + @pytest.mark.asyncio async def test_runner_applies_outer_wall_timeout_to_streaming_requests(): from nanobot.agent.hook import AgentHook, AgentHookContext