From fde55d06e2382c17beadb12ee7002f73270522bc Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:02:31 +0800 Subject: [PATCH] fix(runner): narrow BaseException catch to Exception in tool execution The tool execution path caught BaseException, which includes KeyboardInterrupt, SystemExit, MemoryError, and GeneratorExit. These should never be caught and converted into conversational error messages. CancelledError is already handled separately. Change except BaseException to except Exception so fatal signals propagate instead of being swallowed. Adds parametrized regression test for KeyboardInterrupt and SystemExit propagation. Fixes #4788 --- nanobot/agent/runner.py | 2 +- tests/agent/test_runner_errors.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) 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