From 919e3d341e6faa8ae86d3d33e2c04a4e76b92bd4 Mon Sep 17 00:00:00 2001 From: Oxygen56 Date: Fri, 28 Aug 2026 21:48:02 +0800 Subject: [PATCH] fix(agent): add retry hint to tool exceptions --- nanobot/agent/tools/execution.py | 22 +++++++++++++++------- tests/agent/test_runner_errors.py | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/execution.py b/nanobot/agent/tools/execution.py index 25bde4ae5..f26f872ef 100644 --- a/nanobot/agent/tools/execution.py +++ b/nanobot/agent/tools/execution.py @@ -43,6 +43,13 @@ _WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = ( ) +def _with_retry_hint(payload: str) -> str: + """Append the recovery hint exactly once.""" + if payload.endswith(_RETRY_HINT): + return payload + return payload + _RETRY_HINT + + async def execute_tool_calls( tools: ToolRegistry, tool_calls: list[ToolCallRequest], @@ -105,7 +112,7 @@ async def _execute_tool_call( "status": "error", "detail": "repeated external lookup blocked", } - return lookup_error + _RETRY_HINT, event + return _with_retry_hint(lookup_error), event prepare_call = cast( Callable[[str, Any], object] | None, @@ -119,6 +126,7 @@ async def _execute_tool_call( if len(prepared_tuple) == 3: tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple) if prep_error: + payload = _with_retry_hint(prep_error) event = { "name": tool_call.name, "status": "error", @@ -126,14 +134,14 @@ async def _execute_tool_call( } handled = _classify_violation( raw_text=prep_error, - soft_payload=prep_error + _RETRY_HINT, + soft_payload=payload, event=event, tool_call=tool_call, workspace_violation_counts=workspace_violation_counts, ) if handled is not None: return handled - return prep_error + _RETRY_HINT, event + return payload, event await hook.before_execute_tool(context, tool_call, tool, params) try: @@ -150,10 +158,9 @@ async def _execute_tool_call( "status": "error", "detail": str(exc), } - payload = f"Error: {type(exc).__name__}: {exc}" + payload = _with_retry_hint(f"Error: {type(exc).__name__}: {exc}") handled = _classify_violation( raw_text=str(exc), - # Preserve legacy exception payloads without the retry hint. soft_payload=payload, event=event, tool_call=tool_call, @@ -165,6 +172,7 @@ async def _execute_tool_call( if is_tool_error_result(result): await hook.on_execute_tool_error(context, tool_call, tool, params, result) + payload = _with_retry_hint(result) event = { "name": tool_call.name, "status": "error", @@ -172,14 +180,14 @@ async def _execute_tool_call( } handled = _classify_violation( raw_text=result, - soft_payload=result + _RETRY_HINT, + soft_payload=payload, event=event, tool_call=tool_call, workspace_violation_counts=workspace_violation_counts, ) if handled is not None: return handled - return result + _RETRY_HINT, event + return payload, event await hook.after_execute_tool(context, tool_call, tool, params, result) diff --git a/tests/agent/test_runner_errors.py b/tests/agent/test_runner_errors.py index f4836e578..a2268a7ff 100644 --- a/tests/agent/test_runner_errors.py +++ b/tests/agent/test_runner_errors.py @@ -52,7 +52,31 @@ async def test_runner_returns_tool_exception_to_model_for_recovery(): {"name": "list_dir", "status": "error", "detail": "boom"} ] tool_message = next(message for message in result.messages if message.get("role") == "tool") + retry_hint = "[Analyze the error above and try a different approach.]" assert "Error: RuntimeError: boom" in tool_message["content"] + assert tool_message["content"].count(retry_hint) == 1 + + +@pytest.mark.asyncio +async def test_tool_execution_does_not_duplicate_existing_retry_hint(): + retry_hint = "\n\n[Analyze the error above and try a different approach.]" + tools = SimpleNamespace( + execute=AsyncMock(return_value=ToolResult.error("Error: boom" + retry_hint)), + ) + + results, events = await execute_tool_calls( + tools, + [ToolCallRequest(id="call_1", name="list_dir", arguments={})], + concurrent=False, + external_lookup_counts={}, + workspace_violation_counts={}, + hook=AgentHook(), + context=AgentHookContext(iteration=0, messages=[]), + ) + + assert results == ["Error: boom" + retry_hint] + assert results[0].count(retry_hint) == 1 + assert events[0]["status"] == "error" @pytest.mark.asyncio