From 48f3cc639072bc92bb442b8b547dd93cae88322b Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 28 Apr 2026 07:04:19 +0000 Subject: [PATCH] fix(agent): stop on workspace violations from tool errors Treat workspace and safety guard failures as fatal regardless of whether they arrive from tool preparation, returned tool output, or raised exceptions. Made-with: Cursor --- nanobot/agent/runner.py | 18 +++++++++++++++++ tests/agent/test_runner.py | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 7dab2ede..c7cf126c 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -764,6 +764,15 @@ class AgentRunner: "status": "error", "detail": prep_error.split(": ", 1)[-1][:120], } + if self._is_workspace_violation(prep_error): + logger.warning( + "Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}", + tool_call.name, + prep_error.replace("\n", " ").strip()[:200], + ) + event["detail"] = ("workspace_violation: " + + prep_error.replace("\n", " ").strip())[:160] + return prep_error, event, RuntimeError(prep_error) return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None try: if tool is not None: @@ -781,6 +790,15 @@ class AgentRunner: if isinstance(exc, AskUserInterrupt): event["status"] = "waiting" return "", event, exc + if self._is_workspace_violation(str(exc)): + logger.warning( + "Tool {} blocked by workspace/safety guard; aborting turn: {}", + tool_call.name, + str(exc).replace("\n", " ").strip()[:200], + ) + event["detail"] = ("workspace_violation: " + + str(exc).replace("\n", " ").strip())[:160] + return f"Error: {type(exc).__name__}: {exc}", event, exc if spec.fail_on_tool_error: return f"Error: {type(exc).__name__}: {exc}", event, exc return f"Error: {type(exc).__name__}: {exc}", event, None diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index d4fdd7a0..86ec18b8 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -312,6 +312,46 @@ async def test_runner_returns_structured_tool_error(): ] +@pytest.mark.asyncio +async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + provider.chat_with_retry = AsyncMock(side_effect=[ + LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})], + ), + LLMResponse(content="should not continue", tool_calls=[]), + ]) + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock( + side_effect=PermissionError("Path /tmp/outside.md is outside allowed directory /workspace") + ) + + runner = AgentRunner(provider) + + result = await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=2, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert provider.chat_with_retry.await_count == 1 + assert result.stop_reason == "tool_error" + assert "outside allowed directory" in (result.error or "") + assert result.tool_events == [ + { + "name": "read_file", + "status": "error", + "detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace", + } + ] + + @pytest.mark.asyncio async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path): from nanobot.agent.runner import AgentRunSpec, AgentRunner