fix(agent): let subagents recover from tool errors

This commit is contained in:
chengyongru
2026-08-25 11:54:40 +08:00
committed by chengyongru
parent 2e7ebeb1ca
commit 8332c604da
12 changed files with 140 additions and 252 deletions
-3
View File
@@ -270,7 +270,6 @@ class AgentLoop:
context_window_tokens: int | None = None,
context_block_limit: int | None = None,
max_tool_result_chars: int | None = None,
fail_on_tool_error: bool | None = None,
provider_retry_mode: str = "standard",
tool_hint_max_length: int | None = None,
cron_service: CronService | None = None,
@@ -404,7 +403,6 @@ class AgentLoop:
disabled_skills=disabled_skills,
max_iterations=self.max_iterations,
max_concurrent_subagents=max_concurrent_subagents,
fail_on_tool_error=fail_on_tool_error,
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
)
self._unified_session = unified_session
@@ -518,7 +516,6 @@ class AgentLoop:
context_window_tokens=context_window_tokens,
context_block_limit=defaults.context_block_limit,
max_tool_result_chars=defaults.max_tool_result_chars,
fail_on_tool_error=defaults.fail_on_tool_error,
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace,
+16 -46
View File
@@ -109,7 +109,6 @@ class AgentRunSpec:
error_message: str | None = _DEFAULT_ERROR_MESSAGE
max_iterations_message: str | None = None
concurrent_tools: bool = False
fail_on_tool_error: bool = False
workspace: Path | None = None
session_key: str | None = None
context_block_limit: int | None = None
@@ -570,7 +569,7 @@ class AgentRunner:
await hook.before_execute_tools(context)
results, new_events, fatal_error = await self._execute_tools(
results, new_events = await self._execute_tools(
spec,
response.tool_calls,
external_lookup_counts,
@@ -601,24 +600,6 @@ class AgentRunner:
}
messages.append(tool_message)
completed_tool_results.append(tool_message)
if fatal_error is not None:
error = f"Error: {type(fatal_error).__name__}: {fatal_error}"
final_content = error
stop_reason = "tool_error"
self._append_final_message(messages, final_content)
context.final_content = final_content
context.error = error
context.stop_reason = stop_reason
await hook.after_iteration(context)
should_continue, injection_cycles = await self._try_drain_injections(
spec, messages, None, injection_cycles,
phase="after tool error",
)
if should_continue:
had_injections = True
length_recovery_parts.clear()
continue
break
checkpoint_model_messages = (
self.context_governor.prepare_for_model(
governance_config,
@@ -1422,11 +1403,11 @@ class AgentRunner:
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
) -> tuple[list[Any], list[dict[str, str]]]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
tool_results: list[tuple[Any, dict[str, str]]] = []
for batch in batches:
if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*(
@@ -1442,7 +1423,7 @@ class AgentRunner:
))
tool_results.extend(batch_results)
else:
batch_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
batch_results: list[tuple[Any, dict[str, str]]] = []
for tool_call in batch:
result = await self._run_tool(
spec,
@@ -1457,13 +1438,10 @@ class AgentRunner:
results: list[Any] = []
events: list[dict[str, str]] = []
fatal_error: BaseException | None = None
for result, event, error in tool_results:
for result, event in tool_results:
results.append(result)
events.append(event)
if error is not None and fatal_error is None:
fatal_error = error
return results, events, fatal_error
return results, events
async def _run_tool(
self,
@@ -1473,7 +1451,7 @@ class AgentRunner:
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[Any, dict[str, str], BaseException | None]:
) -> tuple[Any, dict[str, str]]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
hint = "\n\n[Analyze the error above and try a different approach.]"
@@ -1488,9 +1466,7 @@ class AgentRunner:
"status": "error",
"detail": "repeated external lookup blocked",
}
if spec.fail_on_tool_error:
return lookup_error + hint, event, RuntimeError(lookup_error)
return lookup_error + hint, event, None
return lookup_error + hint, event
prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(spec.tools, "prepare_call", None),
@@ -1517,9 +1493,7 @@ class AgentRunner:
)
if handled is not None:
return handled
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
return prep_error + hint, event
await hook.before_execute_tool(context, tool_call, tool, params)
try:
if tool is not None:
@@ -1546,9 +1520,7 @@ class AgentRunner:
)
if handled is not None:
return handled
if spec.fail_on_tool_error:
return payload, event, exc
return payload, event, None
return payload, event
if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
@@ -1566,9 +1538,7 @@ class AgentRunner:
)
if handled is not None:
return handled
if spec.fail_on_tool_error:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
return result + hint, event
await hook.after_execute_tool(context, tool_call, tool, params, result)
@@ -1578,7 +1548,7 @@ class AgentRunner:
detail = "(empty)"
elif len(detail) > 120:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
return result, {"name": tool_call.name, "status": "ok", "detail": detail}
# SSRF is a hard security block at the tool boundary, but the agent turn
# should recover conversationally instead of aborting the runtime.
@@ -1631,7 +1601,7 @@ class AgentRunner:
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None] | None:
) -> tuple[Any, dict[str, str]] | None:
"""Classify safety-boundary failures, or return ``None`` to pass through."""
if self._is_ssrf_violation(raw_text):
logger.warning(
@@ -1640,7 +1610,7 @@ class AgentRunner:
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = self._event_detail("ssrf_violation: ", raw_text)
return self._ssrf_soft_payload(raw_text), event, None
return self._ssrf_soft_payload(raw_text), event
if self._is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
@@ -1658,8 +1628,8 @@ class AgentRunner:
"workspace_violation_escalated: ",
raw_text,
)
return escalation, event, None
return soft_payload, event, None
return escalation, event
return soft_payload, event
return None
+3 -35
View File
@@ -13,7 +13,7 @@ from typing import Any, Callable, NotRequired, TypedDict
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunResult, AgentRunSpec
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.context import (
RequestContext,
@@ -104,7 +104,6 @@ class SubagentManager:
disabled_skills: list[str] | None = None,
max_iterations: int | None = None,
max_concurrent_subagents: int | None = None,
fail_on_tool_error: bool | None = None,
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
):
if workspace is None:
@@ -148,11 +147,6 @@ class SubagentManager:
if max_concurrent_subagents is not None
else defaults.max_concurrent_subagents
)
self.fail_on_tool_error = (
fail_on_tool_error
if fail_on_tool_error is not None
else defaults.fail_on_tool_error
)
self.runner = AgentRunner()
self._exec_session_manager = ExecSessionManager()
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
@@ -346,7 +340,7 @@ class SubagentManager:
self._session_tasks.setdefault(session_key, set()).add(task_id)
try:
result = await inline_task
if status.phase == "error" or status.stop_reason in {"error", "tool_error"}:
if status.phase == "error" or status.stop_reason == "error":
return ToolResult.error(result)
return result
finally:
@@ -416,7 +410,6 @@ class SubagentManager:
max_iterations_message="Task completed but no final response was generated.",
finalize_on_max_iterations=False,
error_message=None,
fail_on_tool_error=self.fail_on_tool_error,
checkpoint_callback=_on_checkpoint,
session_key=sess_key,
workspace=root,
@@ -433,11 +426,7 @@ class SubagentManager:
status.phase = "done"
status.stop_reason = result.stop_reason
if result.stop_reason == "tool_error":
status.tool_events = list(result.tool_events)
final_result = self._format_partial_progress(result)
final_status = "error"
elif result.stop_reason == "error":
if result.stop_reason == "error":
final_result = result.error or "Error: subagent execution failed."
final_status = "error"
else:
@@ -518,27 +507,6 @@ class SubagentManager:
await self.bus.publish_inbound(msg)
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
@staticmethod
def _format_partial_progress(result: AgentRunResult) -> str:
completed = [e for e in result.tool_events if e["status"] == "ok"]
failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None)
lines: list[str] = []
if completed:
lines.append("Completed steps:")
for event in completed[-3:]:
lines.append(f"- {event['name']}: {event['detail']}")
if failure:
if lines:
lines.append("")
lines.append("Failure:")
lines.append(f"- {failure['name']}: {failure['detail']}")
if result.error and not failure:
if lines:
lines.append("")
lines.append("Failure:")
lines.append(f"- {result.error}")
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
"""Build a focused system prompt for the subagent."""
from nanobot.agent.skills import SkillsLoader
-1
View File
@@ -129,7 +129,6 @@ class AgentDefaults(Base):
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
max_tool_iterations: int = 200
max_concurrent_subagents: int = Field(default=1, ge=1)
fail_on_tool_error: bool = True
max_tool_result_chars: int = 16_000
provider_retry_mode: Literal["standard", "persistent"] = "standard"
tool_hint_max_length: int = Field(