fix(agent): add retry hint to tool exceptions

This commit is contained in:
Oxygen56
2026-08-30 17:03:51 +08:00
committed by Xubin Ren
parent 2c55934198
commit 919e3d341e
2 changed files with 39 additions and 7 deletions
+15 -7
View File
@@ -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( async def execute_tool_calls(
tools: ToolRegistry, tools: ToolRegistry,
tool_calls: list[ToolCallRequest], tool_calls: list[ToolCallRequest],
@@ -105,7 +112,7 @@ async def _execute_tool_call(
"status": "error", "status": "error",
"detail": "repeated external lookup blocked", "detail": "repeated external lookup blocked",
} }
return lookup_error + _RETRY_HINT, event return _with_retry_hint(lookup_error), event
prepare_call = cast( prepare_call = cast(
Callable[[str, Any], object] | None, Callable[[str, Any], object] | None,
@@ -119,6 +126,7 @@ async def _execute_tool_call(
if len(prepared_tuple) == 3: if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple) tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
if prep_error: if prep_error:
payload = _with_retry_hint(prep_error)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
"status": "error", "status": "error",
@@ -126,14 +134,14 @@ async def _execute_tool_call(
} }
handled = _classify_violation( handled = _classify_violation(
raw_text=prep_error, raw_text=prep_error,
soft_payload=prep_error + _RETRY_HINT, soft_payload=payload,
event=event, event=event,
tool_call=tool_call, tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts, workspace_violation_counts=workspace_violation_counts,
) )
if handled is not None: if handled is not None:
return handled return handled
return prep_error + _RETRY_HINT, event return payload, event
await hook.before_execute_tool(context, tool_call, tool, params) await hook.before_execute_tool(context, tool_call, tool, params)
try: try:
@@ -150,10 +158,9 @@ async def _execute_tool_call(
"status": "error", "status": "error",
"detail": str(exc), "detail": str(exc),
} }
payload = f"Error: {type(exc).__name__}: {exc}" payload = _with_retry_hint(f"Error: {type(exc).__name__}: {exc}")
handled = _classify_violation( handled = _classify_violation(
raw_text=str(exc), raw_text=str(exc),
# Preserve legacy exception payloads without the retry hint.
soft_payload=payload, soft_payload=payload,
event=event, event=event,
tool_call=tool_call, tool_call=tool_call,
@@ -165,6 +172,7 @@ async def _execute_tool_call(
if is_tool_error_result(result): if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result) await hook.on_execute_tool_error(context, tool_call, tool, params, result)
payload = _with_retry_hint(result)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
"status": "error", "status": "error",
@@ -172,14 +180,14 @@ async def _execute_tool_call(
} }
handled = _classify_violation( handled = _classify_violation(
raw_text=result, raw_text=result,
soft_payload=result + _RETRY_HINT, soft_payload=payload,
event=event, event=event,
tool_call=tool_call, tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts, workspace_violation_counts=workspace_violation_counts,
) )
if handled is not None: if handled is not None:
return handled return handled
return result + _RETRY_HINT, event return payload, event
await hook.after_execute_tool(context, tool_call, tool, params, result) await hook.after_execute_tool(context, tool_call, tool, params, result)
+24
View File
@@ -52,7 +52,31 @@ async def test_runner_returns_tool_exception_to_model_for_recovery():
{"name": "list_dir", "status": "error", "detail": "boom"} {"name": "list_dir", "status": "error", "detail": "boom"}
] ]
tool_message = next(message for message in result.messages if message.get("role") == "tool") 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 "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 @pytest.mark.asyncio