Turning Tool Failures into Data — How to Design a Self-Healing Loop by Injecting Error Objects into Context in OpenClaw
When I first deployed an agent to production, the most bewildering moment was when an external API returned a 503 and the agent simply stopped. It would throw an exception, or fall into an infinite waiting state. Honestly, my first thought back then was "so what am I supposed to do about this?" — but in the end, the core insight was a very simple shift in perspective. Treat tool failures not as a signal to halt execution, but as data the model can read and interpret.
Looking at OpenClaw GitHub Issue #8288, there is an actual bug report of an agent falling into a waiting state for up to 600 seconds on a 503 error. This is not simply a problem with retry logic. It is a problem that arises because the agent does not "know" about the failure. If the error text never enters the model's context window, the model cannot even recognize that it is stuck.
In this post, we will cover how to inject error objects into context using OpenClaw's ToolResult struct and the ErrorBoundary concept, and how to design a hierarchical recovery loop from SELF_CORRECT to FALLBACK_TOOL. We will also look at patterns for use with LangGraph and PydanticAI.
An Error Message Is the Next Prompt
Why Traditional Exception Handling Doesn't Fit
In traditional server applications, exception handling was simply "catch it, log it, return a 500 to the user." But in agent systems, this pattern is fundamentally mismatched.
An agent operates on a loop of reasoning–action–observation. Tool call results are fed back to the model as observations, so if the observation on failure is an empty value or a blob of a Python stack trace, the model cannot make any judgment at all.
Put another way, the reason for the failure, the context, and recovery hints must be present in the model's context as natural language before the model can decide its next action. Treating errors as "observation data" rather than "exceptions" is the starting point for designing self-recovery.
Flow of the OpenClaw Agent Loop
OpenClaw's agent loop is implemented as a serialized state machine. Tool execution results are always returned as ToolResult objects, and this object becomes the input to the next context assembly step.
The key is step G. Depending on the value of the recoverable field in ToolResult, the path splits between recovery and escalation. Designing this branch correctly determines most of an agent's stability.
Understanding the ToolResult Struct
The following is a conceptual example showing what information OpenClaw's ToolResult should carry. For actual field names and signatures, please refer to the Agent loop page in the OpenClaw official documentation.
# Conceptual example — minimum information a ToolResult should carry
from dataclasses import dataclass
from typing import Any
@dataclass
class ToolResult:
success: bool
error: str | None # Natural language error description the model can read
recoverable: bool # True: attempt recovery, False: escalate immediately
data: Any | None = None # Return data on successThe content of the error field is critically important here. If it only contains a technical code like "HTTP 503", the model cannot determine a direction for recovery. It must include a natural language description with the reason (why), a hint, and whether a retry is possible.
# Bad example
ToolResult(
success=False,
error="HTTPError: 503",
recoverable=True,
)
# Good example
ToolResult(
success=False,
error=(
"The external payment API is temporarily unavailable (HTTP 503). "
"The service is likely overloaded. "
"Consider using the cached payment method list instead, "
"or skipping this step and retrying later."
),
recoverable=True,
)This difference determines whether the model can recognize "the service is temporarily unavailable" and autonomously decide to use the cache or skip the task.
Designing a Hierarchy of Recovery Strategies
Four Basic Strategies and When to Apply Them
Here is a summary of recovery strategies commonly used in OpenClaw-family frameworks and when to use them.
| Strategy | When to Apply | Caution |
|---|---|---|
SELF_CORRECT |
Model judgment error, wrong arguments passed | Outcome depends on error quality |
RETRY_ONCE |
Network timeout, transient connection error | Root cause not resolved |
EXPONENTIAL_BACKOFF |
Rate limit, service overload | Recommended to delegate to infrastructure layer |
FALLBACK_TOOL |
Primary tool keeps failing, alternative tool exists | Need to verify fallback result quality |
The most frequently used strategy is SELF_CORRECT. It injects the error text directly into the next reasoning cycle's context, letting the model judge for itself "what went wrong and how to fix it."
SELF_CORRECT in Practice
# Conceptual example — SELF_CORRECT strategy implementation
import os
MAX_RETRIES = int(os.getenv("OPENCLAW_MAX_RETRIES", "3"))
def run_with_self_correct(agent, tool_call, context):
for attempt in range(MAX_RETRIES):
result = agent.execute_tool(tool_call, context)
if result.success:
return result
if not result.recoverable:
raise NonRecoverableError(result.error)
context.add_observation(
role="tool_error",
content=(
f"[Tool execution failed — attempt {attempt + 1}/{MAX_RETRIES}]\n"
f"Tool: {tool_call.name}\n"
f"Error: {result.error}\n"
f"Retryable: {result.recoverable}\n\n"
"Please refer to the error above and try a different approach."
),
)
tool_call = agent.replan(context)
return ToolResult(
success=False,
error=f"Exceeded maximum retry count ({MAX_RETRIES}).",
recoverable=False,
)One tip: in addition to an upper limit on retries, it is good practice to also set an upper limit on the wait time between retries. Do not mix both values into a single environment variable; keep them separate with distinct names.
Building a Fallback Chain with FALLBACK_TOOL
This is the pattern of detecting recoverable=True and automatically switching to an alternative tool. Think of a data query agent where, if the primary SQL tool fails, it switches to a read-only cache tool.
# Conceptual example — FALLBACK_TOOL chain
TOOL_FALLBACK_CHAIN = {
"sql_query_tool": "readonly_cache_tool",
"payment_api_tool": "cached_payment_data_tool",
"realtime_search_tool": "indexed_search_tool",
}
def run_with_fallback(agent, tool_call, context):
result = agent.execute_tool(tool_call, context)
if result.success:
return result
fallback_tool_name = TOOL_FALLBACK_CHAIN.get(tool_call.name)
if not fallback_tool_name or not result.recoverable:
return result
context.add_observation(
role="tool_error",
content=(
f"{tool_call.name} failed. Switching to {fallback_tool_name}. "
f"Reason: {result.error}"
),
)
fallback_call = tool_call.with_name(fallback_tool_name)
return agent.execute_tool(fallback_call, context)ErrorBoundary as a Conceptual Isolation Zone
The approach of grouping specific tools or steps into an "error isolation zone" and declaring a recovery strategy in advance is a pattern that appears commonly across many frameworks. The following is a conceptual example only. For the class name and signature that OpenClaw uses to expose this concept, please refer to the Retry policy documentation and the Tool plugins documentation.
# Conceptual example — idea for declaring an isolation zone
class ErrorBoundary:
def __init__(self, strategy: str, **params):
self.strategy = strategy
self.params = params
enrichment_boundary = ErrorBoundary(
strategy="skip_and_note",
note_template="Skipping the external data enrichment step. Reason: {error}",
)
cache_boundary = ErrorBoundary(
strategy="use_cached",
cache_key="last_successful_result",
)
payment_boundary = ErrorBoundary(
strategy="ask_human",
prompt="The payment processing tool has failed. Would you like to handle this manually?",
)The core idea is to declare "what constitutes the isolation zone and what to do when something fails inside it" separately from the code flow.
In-Session Self-Correction Loops
Accumulating Self-Critique in Context
The Reflexion paper by Shinn et al. (2023) describes a structure that stores episode-level self-evaluation results in external memory and injects them into the next episode. What this post covers is structurally different: an in-session self-correction loop. It shares inspiration from Reflexion, but the only thing they have in common is that self-critique about "why it failed" is accumulated in context within the same session and fed into the next attempt.
This pattern is especially effective in code generation agents. When a compilation error is placed in ToolResult.error and passed to the next reasoning cycle, the model reads the incorrect import path or type mismatch and generates corrected code.
# Conceptual example — in-session self-correction loop
def self_correction_loop(agent, task, max_attempts=5):
context = agent.build_context(task)
critique_history = []
for attempt in range(max_attempts):
result = agent.execute(context)
if result.success:
return result
critique = agent.generate_critique(
task=task,
attempt=result,
error=result.error,
history=critique_history,
)
critique_history.append(critique)
context.add_reflection(
content=f"Attempt {attempt + 1} failure analysis:\n{critique}"
)
return ToolResult(
success=False,
error="Maximum attempt count exceeded",
recoverable=False,
)Loop Detection and Forced Intervention
A situation where an agent keeps calling the same tool with the same arguments is common in practice. Left unchecked, this quietly causes API costs to explode. The following is a conceptual example that detects exact repetition of identical tool calls. Catching "semantically equivalent but differently expressed calls" requires embedding-based similarity, but most practical cases are handled well enough by exact duplicate detection alone.
# Conceptual example — detecting exact repetition of identical tool calls
from collections import deque
import hashlib
class LoopDetector:
def __init__(self, window=3, threshold=3):
self.recent_steps = deque(maxlen=window)
self.threshold = threshold
def record(self, step):
canonical = f"{step.tool_name}:{sorted(step.args.items())}"
step_hash = hashlib.md5(canonical.encode()).hexdigest()
self.recent_steps.append(step_hash)
def is_looping(self) -> bool:
if len(self.recent_steps) < self.threshold:
return False
return len(set(self.recent_steps)) == 1
def get_reflection_prompt(self) -> str:
return (
"The current approach is failing repeatedly. "
"Please review the methods tried so far and "
"choose an entirely different tool or strategy."
)When this detection logic is triggered, a "reflection prompt" is forcibly injected into the next context to cause the model to replan. Check the OpenClaw official documentation to see if the framework provides this as a built-in feature; if not, you can layer on something like the above yourself.
Using with LangGraph and PydanticAI
Routing to Recovery Nodes via LangGraph Conditional Edges
Routing to a recovery node via a conditional edge when a tool error occurs in LangGraph is one of the most commonly used patterns. You can find it with actual signatures in the Add and manage memory / Error handling examples in the official documentation.
# Conceptual example — error routing in LangGraph
from langgraph.graph import StateGraph, END
from typing import TypedDict, Literal
class AgentState(TypedDict):
messages: list
error_count: int
last_error: str | None
def route_on_error(state: AgentState) -> Literal["recover", "escalate", "continue"]:
if state.get("last_error") is None:
return "continue"
if state["error_count"] >= 3:
return "escalate"
return "recover"
def recovery_node(state: AgentState) -> AgentState:
error_message = {
"role": "tool",
"content": f"Previous attempt failed: {state['last_error']}. Please try a different approach.",
}
return {
**state,
"messages": state["messages"] + [error_message],
"last_error": None,
}
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_node("recover", recovery_node)
graph.add_node("escalate", escalate_node)
graph.add_conditional_edges(
"tools",
route_on_error,
{"continue": "agent", "recover": "recover", "escalate": END},
)
graph.add_edge("recover", "agent")Re-prompting on Validation Failure with PydanticAI ModelRetry
In PydanticAI (based on Pydantic v2), raising ModelRetry inside a tool function or result validator sends its message as a re-prompt to the model. The actual usage pattern is to raise it inside the tool function body or a result validator, not inside a Pydantic field validator.
# Conceptual example — using ModelRetry in PydanticAI
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
from pydantic_ai.exceptions import ModelRetry
class AnalysisResult(BaseModel):
summary: str
confidence: float
agent = Agent(
model="claude-sonnet-5",
result_type=AnalysisResult,
retries=3,
)
@agent.result_validator
async def validate_confidence(ctx: RunContext, result: AnalysisResult) -> AnalysisResult:
if not 0.0 <= result.confidence <= 1.0:
raise ModelRetry(
f"confidence must be between 0.0 and 1.0. Received: {result.confidence}"
)
return resultThis allows the model to read the error message and regenerate a response even for cases where "the structure is correct but the value is invalid."
Separating the Infrastructure Layer from the Agent Layer
I also initially implemented rate limit handling directly inside the agent loop, and experienced a significant increase in complexity. It is much cleaner to clearly separate the two kinds of failures.
According to OpenClaw's Model failover documentation, it has a built-in model failover feature that retries with the same provider's authentication profile on overload and rate limit errors before switching to a fallback model. Handling infrastructure concerns unrelated to agent logic — like rate limits — at a layer such as an LiteLLM proxy, while letting the agent loop focus solely on actual task recovery, keeps the code significantly simpler.
Trade-offs and Common Mistakes
Summary of Pros and Cons
| Aspect | Advantage | Risk |
|---|---|---|
| Recovery autonomy | Model selects recovery path without human intervention | Poorly designed loops can cause cost explosions |
| Observability | Structured error objects make logging and analysis easy | Low-quality errors leave the model without direction |
| Progressive recovery | Layered RETRY → FALLBACK → REPLAN → ESCALATE | Silent quality degradation that looks like a successful fallback |
| Context accumulation | Reduces repeated identical errors within a session | Risk of context window pollution in long runs |
Common Mistakes in Practice
Self-recovery loops without a maximum retry limit. You must always set both an upper limit on retry count and an upper limit on wait time. Without them, a failing tool call can incur enormous API costs within minutes.
Putting stack traces directly into the error field. Python stack traces are difficult for a model to use for recovery. Provide a natural language description that explains the cause, a hint, and whether a retry is possible.
Treating all failures as recoverable without classifying errors. If you do not properly classify the recoverable field, retries will repeat even for non-recoverable errors. It is important to define schemas for recoverable and non-recoverable errors in advance.
Failing to detect silent quality degradation. Even if a fallback tool responds successfully, it may return results of far lower quality than the original tool. Include a result quality validation step in your fallback chain.
Bypassing simulated tool calls. There is a reported issue (#8288) in OpenClaw where simulated tool calls can be permitted, so it is strongly recommended to verify before production deployment that the error injection flow is not being inadvertently bypassed.
If you are looking to scale to a multi-agent system, it is also worth examining the bulkhead pattern, which prevents failures in individual agents from cascading through the entire pipeline.
Closing
In the end, designing a self-recovery loop converges on a single principle.
Errors are data, not exceptions.
The model can make decisions when it has information, and stops when it does not. Whether you leave error text as a blob of a stack trace, or transform it into a natural language sentence conveying "what failed, why, and what to try next" — this single difference determines whether your agent becomes "a system that freezes in the face of failure" or "a system that plays its next move on its own." The next time your agent fails, I encourage you to open it up and look at what sentence that failure left behind in the model's context. Start there.
References
- Agent loop · OpenClaw Official Documentation
- Retry policy · OpenClaw Official Documentation
- Model failover · OpenClaw Official Documentation
- Tool plugins · OpenClaw Official Documentation
- Bug #8288: Agent hangs on failed tool calls — openclaw/openclaw GitHub
- Reflexion: Language Agents with Verbal Reinforcement Learning — Shinn et al., 2023
- LangGraph Official Documentation
- PydanticAI Official Documentation
- LiteLLM Official Documentation
- Saving Crashed AI Agents: Simple Recovery for OpenClaw — Level Up Coding
- Mastering the OpenClaw Agentic Loop Upgrade — DEV Community
- Tool Call Failures in OpenClaw: Diagnosis & Solutions — ShopClawMart
- Failover and Retry Logic — DeepWiki
- AI Agent Failure Modes: Tool-Calling Errors, Infinite Loops & Propagation — Openlayer
- Agentic AI self-correction: How to build systems that fix their own mistakes — Weights & Biases
- LangGraph Error Handling: Retries & Fallback Strategies — machinelearningplus
- Agentic loops explained: From ReAct to loop engineering — Data Science Dojo
- Dissecting OpenClaw — Medium (Sau Sheong)
- How OpenClaw Works: Understanding AI Agents Through a Real Architecture — Medium (Bibek Poudel)
- AI Agent Error Handling: Best Practices & Patterns — Fastio
- AI Agent Retry Patterns - Exponential Backoff Guide — Fastio