diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 7c84e032..7729013e 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -1174,7 +1174,7 @@ class AgentRunner: result = await spec.tools.execute(tool_call.name, params) except asyncio.CancelledError: raise - except BaseException as exc: + except Exception as exc: await hook.on_execute_tool_error(context, tool_call, tool, params, exc) event = { "name": tool_call.name, diff --git a/tests/agent/test_runner_errors.py b/tests/agent/test_runner_errors.py index fffbbac2..a9c42621 100644 --- a/tests/agent/test_runner_errors.py +++ b/tests/agent/test_runner_errors.py @@ -3,6 +3,7 @@ session message isolation, and tool result preservation.""" from __future__ import annotations +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -45,6 +46,39 @@ async def test_runner_returns_structured_tool_error(): ] +@pytest.mark.asyncio +@pytest.mark.parametrize("control_error", [KeyboardInterrupt, SystemExit]) +async def test_runner_propagates_tool_control_flow_exceptions(control_error: type[BaseException]): + from nanobot.agent.runner import AgentRunner + + provider = MagicMock(spec=LLMProvider) + + async def execute(_name, _args): + raise control_error("stop") + + tools = SimpleNamespace( + get_definitions=lambda: [], + execute=execute, + ) + runner = AgentRunner() + spec = make_run_spec( + provider, + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) + + with pytest.raises(control_error): + await runner._run_tool( + spec, + ToolCallRequest(id="call_1", name="list_dir", arguments={}), + external_lookup_counts={}, + workspace_violation_counts={}, + ) + + @pytest.mark.asyncio async def test_llm_error_not_appended_to_session_messages(): """When LLM returns finish_reason='error', the error content must NOT be