Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Memory preserves context across an agent run; recovery keeps that run bounded when tools, state, or generated decisions go wrong.
Traditional software and LLM agents can both fail loudly or quietly. Agent failures are especially easy to miss when the process returns a plausible answer while selecting a nonexistent tool, looping on a task, or claiming a side effect occurred without verified evidence. Without runtime controls, one stuck run can waste budget or send a user an unsupported answer.
A deploy-status agent for a CI/CD platform makes the failure surface concrete. A developer asks, "What happened to run RUN-842?" The agent plans to call the get_run tool with the run ID, fetch the build status, and return a concise summary. On a good day, this works perfectly. On a bad day, the agent might invent a tool called search_runs, send the wrong parameter name, or loop forever rephrasing the same query while the developer waits. Papers like ReAct (Reason + Act)[1] and Toolformer[2] made tool-using LLMs practical, but they didn't make them reliable by default. Production agents need defensive architecture that assumes failure is normal, not exceptional.
Start with a concrete deploy-status agent, watch it fail in realistic ways, and then add validation checks, retry policy, circuit breakers, and bounded fallback paths. Make failure explicit before generated claims or uncertain writes reach a user.
Why is "fail transparently" a better goal than "never fail" for production agents?
Answer
LLM agents are probabilistic and depend on tools, state, prompts, and external services. You can't eliminate every bad trajectory. You can make failures visible, bounded, reversible, and recoverable before they harm users or trigger duplicate side effects.
Why agent failures require runtime checks
Agent systems inherit ordinary software faults and add generated decisions that may be syntactically valid but unsupported. Compare the recovery controls needed at execution time:
| Failure Mode | Traditional Software | LLM Agent | Recovery Strategy |
|---|---|---|---|
| Invalid input | Rejected with error message | Agent hallucinates a "valid" response | Validation checks: Schema checks before tool execution |
| Infinite loop | CPU spin or runaway resource use | Agent keeps calling the same tool with slight variations | Semantic Loop Detection: Hash canonicalized actions and compare intent similarity |
| Service unavailable | Connection timeout | Agent fabricates the API response | Circuit Breaker: Fail fast and fallback to static logic |
| Logic error | Wrong result, sometimes unnoticed | Plausible but unsupported answer or tool choice | Verifier + source check: Validate against deterministic evidence where available |
This additional risk stems from generated actions and answers. An LLM acting inside an agent can vary its tool selection or interpretation after a small change in input or sampling. Agent architecture still prevents known errors, but it also has to detect degraded trajectories while they're running.
Because you can't exhaustively unit-test every possible conversational path or generated output, the center of gravity shifts toward runtime safeguards and evaluation harnesses. In a deterministic system, you can enumerate many edge cases before deployment. In an agentic system, you still need offline evals, but you also need guardrails that constrain the model during execution. When the model drifts off course, the system surrounding it must detect the drift and either steer it back or abort the operation safely before it causes compounding harm.
What moves from test time to runtime when software becomes agentic?
Answer
Some correctness checks must run during execution: schema validation, tool allowlists, step limits, loop detection, budget guards, verifier checks, and side-effect reconciliation. Offline tests still matter, but they can't cover every generated action path.
Why small per-step errors compound
One number explains why agent reliability is hard. A task that needs n dependent steps succeeds only if every step succeeds. If each step is independent and succeeds with probability p, the whole task succeeds with probability:
This product decays fast. Even a per-step success rate that sounds excellent collapses over a long trajectory:
Per-step success p | 5 steps | 20 steps | 50 steps |
|---|---|---|---|
| 99% | 95% | 82% | 61% |
| 95% | 77% | 36% | 8% |
| 90% | 59% | 12% | 0.5% |
A 95%-reliable step can look acceptable in a one-shot demo and becomes nearly useless for a 50-step research agent. The same model can look impressive in a demo and fall apart in production because the demo was short. The model didn't get worse, the trajectory got longer.
This compounding is why long-horizon agents still break in production. In METR's 2025 task-horizon analysis, frontier models at the time improved their 50%-reliable task length quickly, with Claude 3.7 Sonnet reaching roughly an hour of human task time on that benchmark.[3] METR's interpretation is that those gains came largely from reliability and the ability to recover from mistakes, rather than raw reasoning alone. Reliability is the bottleneck, and part of that bottleneck is engineering. Model quality alone doesn't remove it.
The benchmark numbers make the same point. On -bench, a tool-agent benchmark over retail and airline tasks, GPT-4o solves a meaningful share of tasks once but its pass^8 (succeeding on all eight independent attempts of the same task) falls below 25% in retail.[4] An agent that's right most of the time is still wrong often enough that, over a long run, failure is the expected outcome unless you engineer around it.
These defenses all attack the same equation. They either raise per-step reliability p (validation checks, action-contract checks, structured feedback) or shrink the number of unrecovered steps that count against you (retries, fallbacks, checkpoints, loop breakers, escalation). You can't make p = 1, so you build a system that survives the steps where p < 1.
An agent step succeeds 98% of the time. Why might a 40-step task still fail more than half the time?
Answer
Dependent steps multiply: 0.98^40 is about 0.45, so the task succeeds under half the time even though each step looks reliable. Long trajectories amplify small per-step error rates, which is why recovery and bounding matter more than raising single-step accuracy alone.
The deploy-status agent: our running example
A single ReAct-style agent is tasked with looking up CI/CD runs. The agent receives a developer question, plans a tool call, executes it, and observes the result. The loop in plain English:
- Developer asks: "What happened to run RUN-842?"
- Agent reasons: "I need to query CI status for run RUN-842."
- Agent acts: Calls
get_runwith{"id": "RUN-842"}. - Agent observes: "Status: failed, failing job: unit-tests."
- Agent answers: "Run RUN-842 failed in unit-tests."
This loop assumes the agent picks the right tool, formats the arguments correctly, and stops after getting an answer. In practice, any of those steps can fail. Each failure section below introduces one symptom and then adds a defense.
Before adding defenses, you should already understand two ideas from earlier in the curriculum:
- The agentic loop (Planning -> Action -> Observation) from the ReAct article.
- Tool execution flow (how an LLM turns a goal into a specific API schema) from the function-calling article.
If those concepts are fuzzy, revisit them first. The recovery patterns below assume that foundation.
A taxonomy of failure
The agent failures fit four operating categories. This isn't the only possible taxonomy, but it gives each symptom an owner and a recovery path.
| Category | Typical Symptom | Root Cause Example |
|---|---|---|
| Planning | Infinite loops | Hallucinating a tool that doesn't exist |
| Action | Syntax errors or wrong parameters | Missing a required JSON field in an API call |
| Reflection | "False success" | Agent thinks it finished but the output is empty or wrong |
| Memory | Context poisoning | Early errors cascading into later steps |
These categories map cleanly onto the six concrete failure states addressed below. Planning failures show up as tool hallucinations and infinite loops. Action failures show up as wrong parameters and task-contract drift. Reflection failures show up as the agent declaring success after a failed run. Memory failures show up as cascading errors in multi-agent systems and context-window overflow.
The illustration below grounds those categories in one deploy-status agent, so the taxonomy stays tied to symptoms you can recognize in production.

A deploy agent says "deployment approved" because an earlier CI pass was hallucinated. Which failure category is this, and what defense should catch it before the reply agent sends the message?
Answer
It's a memory or state failure: false context poisoned downstream reasoning. A validated handoff should check the CI scan against source-of-truth data before downstream agents use it, and non-idempotent side effects should use reconciliation plus idempotency keys.
Worked example: the wrong parameter
Start with the simplest failure and the simplest fix. Our deploy-status agent has a tool called get_run that expects {"id": "RUN-842"}. Suppose the agent sends {"run_id": "RUN-842"} instead. The API returns a 400 Bad Request with the message: Missing required parameter 'id'.
A naive agent sees the error and tries the exact same call again. Nothing has changed, so it gets the exact same 400. That's the "just try again" anti-pattern. Without explaining why it failed, the agent is likely to repeat the error forever.
Feed the error back into the agent's context as structured feedback, rather than a raw exception string. The agent can then compare its last action with the error and the tool schema, correct the key name, and retry. That's the foundation of reflexion-style recovery: the agent reflects on its failure, diagnoses the cause, and refines its next attempt.[5]
The corrected loop looks like this:
1Step 1: get_run({"run_id": "RUN-842"}) -> 400 Bad Request: Missing required parameter 'id'.
2Step 2: Agent reflects: "I used 'run_id' but the schema requires 'id'."
3Step 3: get_run({"id": "RUN-842"}) -> 200 OK: Status failed, failing job unit-tests.The retry must carry new information. The same prompt with the same context will likely produce the same wrong answer. You need to tell the model exactly what went wrong in a format it can act on.
Why is the second get_run({"id": "RUN-842"}) attempt different from a blind retry?
Answer
The retry includes new diagnostic information: the last call used run_id, but the schema requires id. That changes the next generation. A blind retry repeats the same context and hopes sampling changes the outcome.
Defense strategy: schema validation
The primary defense is strict JSON schema validation. Modern model APIs often support tool calling or structured outputs, but you must still validate arguments before execution and apply authorization separately. If the arguments are invalid, the system returns a structured error message to the repair loop rather than executing the call.
Pydantic (in Python) or Zod (in TypeScript) can enforce these schemas. This validation function models the actual get_run({"id": "RUN-842"}) tool contract and returns concise correction feedback:
1from pydantic import BaseModel, ConfigDict, Field, ValidationError
2
3class ToolCallError(Exception):
4 pass
5
6class QueryRunArgs(BaseModel):
7 model_config = ConfigDict(extra="forbid")
8 id: str = Field(..., pattern=r"^RUN-[0-9]{3,}$")
9
10def validate_tool_call(raw_args: dict[str, object]) -> QueryRunArgs:
11 try:
12 return QueryRunArgs(**raw_args)
13 except ValidationError as exc:
14 field = exc.errors()[0]["loc"][0]
15 raise ToolCallError(f"get_run requires valid field: {field}") from exc
16
17try:
18 validate_tool_call({"run_id": "RUN-842"})
19except ToolCallError as exc:
20 print("rejected:", exc)
21print("accepted:", validate_tool_call({"id": "RUN-842"}).id)1rejected: get_run requires valid field: id
2accepted: RUN-842Common mistake: Returning a raw Python traceback to the LLM. Standard error messages are often too noisy for LLMs. Agentic debugging involves isolating the minimal set of root-cause failures rather than dumping every surface-level exception. Feed the model a one-sentence description of what's wrong, not a 50-line stack trace.
The agent called get_run({"run_id": "RUN-842"}), and the tool returned Missing required parameter 'id'. What feedback should the next retry receive?
Answer
The retry should receive structured feedback that names the mismatch: "The tool requires id, but the last call used run_id." The model needs that new information; repeating the same prompt or dumping a raw traceback is likely to repeat the failure.
Tool call hallucination
One of the most frequent failures is tool misuse. In practice, that usually means one of two concrete problems: the model invents a tool that doesn't exist, or it emits malformed arguments for a real tool. Because LLMs are predictive text engines, if they lack sufficient context to execute a task, they often hallucinate a plausible-sounding tool instead of asking for clarification.
For example, our deploy-status agent might output:
1// Model generates this:
2{"tool": "search_runs", "args": {"query": "run RUN-842", "format": "json"}}
3// But the actual API expects:
4{"tool": "get_run", "params": {"id": "RUN-842"}}The model invented search_runs because the name sounds reasonable. Without a validation check, this call would be executed against a non-existent endpoint, producing a confusing error that the agent might misinterpret.
Defense strategy
Use the same schema validation from above, plus a tool allowlist. Before executing any tool call, check whether the requested tool name exists in the registered tool set. If it doesn't, return a clear error: Tool 'search_runs' is not available. Available tools: get_run, get_logs, suggest_alternative.
This transforms an ambiguous failure into structured feedback the agent can use to self-correct.
Why should hallucinated tools be rejected before any execution attempt?
Answer
The tool name is part of the security and capability boundary. If search_runs isn't registered, the runtime shouldn't infer a nearby endpoint or let the model improvise. It should reject the call and show the allowed tool names.
Infinite loops (the "stuck agent")
The loop bucket contains two common states: exact repeats and semantic repeats. In both cases, the agent repeatedly calls the same tool or oscillates between two states. A deploy-status agent might produce this trace:
1Step 1: get_run({"id": "RUN-842"}) -> "Failed in unit-tests"
2Step 2: get_run({"id": "RUN-842"}) -> "Failed in unit-tests"
3Step 3: get_run({"id": "RUN-842"}) -> "Failed in unit-tests"
4... (continues forever)Or with semantic rephrasing:
1Step 1: get_run({"id": "RUN-842"}) -> "Failed in unit-tests"
2Step 2: search("status for run RUN-842") -> "Failed in unit-tests"
3Step 3: search("RUN-842 failing job") -> "Failed in unit-tests"
4Step 4: search("where is run RUN-842") -> "Failed in unit-tests"
5... (continues forever)These loops typically occur when the runtime doesn't make progress explicit. A vague stopping condition, weak error feedback, or missing state check can let the model propose the same approach again with slightly different wording. The model isn't required to prove that its new action differs from the failed one, so the surrounding system has to detect repetition.
Defense strategy
Multi-layered loop detection is the key. The LoopBreaker class provides a practical implementation of this defense by maintaining a stateful history of actions. Its check method takes a proposed tool name and its arguments as inputs to evaluate against past behavior. It combines exact deduplication, repeated-tool heuristics, and approximate intent similarity, then returns True if the current call would push the agent over a loop threshold:
1import difflib
2import hashlib
3import json
4import re
5
6class LoopBreaker:
7 def __init__(
8 self,
9 max_steps: int = 15,
10 max_identical: int = 3,
11 same_tool_streak: int = 5,
12 semantic_threshold: float = 0.9,
13 ):
14 self.max_steps = max_steps
15 self.max_identical = max_identical
16 self.same_tool_streak = same_tool_streak
17 self.semantic_threshold = semantic_threshold
18 self.tool_call_history: list[dict[str, str]] = []
19
20 def _stable_call_hash(self, tool_name: str, args: dict[str, object]) -> str:
21 canonical_args = json.dumps(
22 args,
23 sort_keys=True,
24 default=str,
25 separators=(",", ":"),
26 )
27 return hashlib.sha256(f"{tool_name}:{canonical_args}".encode("utf-8")).hexdigest()
28
29 def _intent_text(self, tool_name: str, args: dict[str, object]) -> str:
30 argument_text = " ".join(str(value).strip().lower() for value in args.values())
31 identifiers = sorted(set(re.findall(r"\b[a-z]+-\d+\b", argument_text)))
32 combined = f"{tool_name.replace('_', ' ')} {argument_text}"
33
34 # Normalize tool-specific phrasing into a task-level intent.
35 if identifiers and "run" in combined:
36 return f"lookup run {' '.join(identifiers)}"
37
38 fields = [tool_name.replace("_", " ")]
39 fields.extend(identifiers)
40 fields.extend(
41 str(args[key]).strip().lower()
42 for key in ("query", "prompt", "text", "task")
43 if args.get(key)
44 )
45 return " ".join(fields)
46
47 def check(self, tool_name: str, args: dict[str, object]) -> bool:
48 """Returns True if a loop is detected."""
49 call = {
50 "tool": tool_name,
51 "args_hash": self._stable_call_hash(tool_name, args),
52 "intent_text": self._intent_text(tool_name, args),
53 }
54
55 # Check for loops BEFORE appending to history
56 # Hard step limit
57 if len(self.tool_call_history) >= self.max_steps:
58 return True
59
60 # Detect repeated identical calls
61 identical_attempts = 1 + sum(
62 1 for h in self.tool_call_history if h["args_hash"] == call["args_hash"]
63 )
64 if identical_attempts >= self.max_identical:
65 return True
66
67 # Detect same-tool repetition streaks
68 same_tool_attempts = 1
69 for h in reversed(self.tool_call_history):
70 if h["tool"] != tool_name:
71 break
72 same_tool_attempts += 1
73 if same_tool_attempts >= self.same_tool_streak:
74 return True
75
76 # Detect approximate intent repetition for rephrased queries/prompts
77 recent_window = self.tool_call_history[-(self.same_tool_streak - 1):]
78 similar_attempts = 1 + sum(
79 1
80 for h in recent_window
81 if h["intent_text"]
82 and difflib.SequenceMatcher(
83 None, h["intent_text"], call["intent_text"]
84 ).ratio() >= self.semantic_threshold
85 )
86 if similar_attempts >= self.max_identical:
87 return True
88
89 # Only append after checks to avoid polluting history with the failing call
90 self.tool_call_history.append(call)
91 return False
92
93breaker = LoopBreaker(max_identical=3)
94print("first:", breaker.check("get_run", {"id": "RUN-842"}))
95print("second:", breaker.check("get_run", {"id": "RUN-842"}))
96print("third:", breaker.check("get_run", {"id": "RUN-842"}))
97
98cross_tool = LoopBreaker(max_identical=2)
99print("cross-tool first:", cross_tool.check("get_run", {"id": "RUN-842"}))
100print("cross-tool repeat:", cross_tool.check("search", {"query": "where is run RUN-842"}))1first: False
2second: False
3third: True
4cross-tool first: False
5cross-tool repeat: TrueNotice that these thresholds include the current attempt. That detail matters because an off-by-one bug here burns a real extra tool call, token round-trip, and latency spike before the breaker trips.
The task-level normalization makes the runnable guard catch get_run({"id": "RUN-842"}) versus search("where is run RUN-842") even though the tool names and argument keys differ. For broader production traffic, replace or augment this narrow normalization with embeddings over a compact task and state summary. Evaluate that semantic detector on both true loops and legitimate follow-up actions against the same entity.
Common mistake: Assuming that
max_stepsalone is enough to prevent loops. A step limit of 15 doesn't stop an agent from wasting 15 steps (and thousands of tokens) on a futile loop. Semantic loop detection stops the bleeding early, often after 2-3 repeated attempts.
Why does the loop breaker track both exact argument hashes and approximate intent text?
Answer
Exact call hashes catch identical repeats such as get_run({"id": "RUN-842"}) without confusing a different tool that happens to accept the same ID. Intent text catches semantic repeats such as search("status for RUN-842"), search("RUN-842 failing job"), and search("what happened to run RUN-842"). Agents often loop by rephrasing, so both signals matter.
Why does the loop breaker append a call only after checks pass?
Answer
Rejected calls shouldn't pollute the normal history. If you record failed loop attempts as successful progress, downstream logic may think the agent advanced. The breaker should block the current call, report the loop reason, and let recovery decide whether to summarize, replan, fallback, or escalate.
Token budget exhaustion
This bucket has two related states: hard context-window overflow and slow token-budget bleed. In a naive agent runtime that appends each tool call and observation to one transcript, prompt size grows roughly linearly with each turn. Across the full run, total token spend can become roughly quadratic because each new step re-sends most of the prior history. If a 20-step task replays its full transcript every time, the final step includes the previous 19 steps.
If this growth is left unchecked, it can cause context-window overflow (commonly rejected with a provider error, or handled via truncation you didn't intend) and unexpectedly high API bills. A runaway agent stuck in a subtle loop can consume a large amount of tokens before anyone notices.
Defense strategy
Effective budget management involves implementing two distinct layers: hard limits and soft management. Hard limits act as a circuit breaker, abruptly stopping the agent if it exceeds a predefined cost or token threshold. Soft management involves proactively monitoring the context window and taking action (like summarizing the history) before limits are breached.
The TokenBudget class below tracks actual token consumption against hard constraints. Its consume method takes the number of input and output tokens reported for each completed generation step. It updates internal state and raises BudgetExhausted when usage crosses a configured limit, blocking any further agent steps. Pair this post-call accounting with a preflight context estimate and provider-side output cap so one generation can't overshoot too far:
1class BudgetExhausted(Exception):
2 pass
3
4class TokenBudget:
5 def __init__(
6 self,
7 max_tokens: int = 100_000,
8 max_cost_usd: float = 1.0,
9 pricing: dict[str, dict[str, float]] | None = None,
10 ):
11 self.max_tokens = max_tokens
12 self.max_cost = max_cost_usd
13 self.used_tokens = 0
14 self.estimated_cost = 0.0
15 self.pricing = pricing or {}
16
17 def consume(self, input_tokens: int, output_tokens: int, model: str):
18 """Updates token counts and raises an error if budget is exceeded."""
19 self.used_tokens += input_tokens + output_tokens
20 self.estimated_cost += self._calculate_cost(input_tokens, output_tokens, model)
21
22 # Hard stop to prevent runaway billing
23 if self.used_tokens > self.max_tokens:
24 raise BudgetExhausted(f"Token limit exceeded: {self.used_tokens}/{self.max_tokens}")
25 if self.estimated_cost > self.max_cost:
26 raise BudgetExhausted(f"Cost limit exceeded: ${self.estimated_cost:.2f}/${self.max_cost:.2f}")
27
28 def _calculate_cost(self, input_tokens: int, output_tokens: int, model: str) -> float:
29 rates = self.pricing.get(model)
30 if rates is None:
31 raise BudgetExhausted(f"Pricing missing for model: {model}")
32
33 input_cost = (input_tokens / 1_000_000) * rates["input"]
34 output_cost = (output_tokens / 1_000_000) * rates["output"]
35 return input_cost + output_cost
36
37budget = TokenBudget(
38 max_tokens=1_000,
39 max_cost_usd=0.01,
40 pricing={"agent-model": {"input": 1.0, "output": 2.0}},
41)
42budget.consume(200, 50, "agent-model")
43print("used tokens:", budget.used_tokens)
44try:
45 budget.consume(900, 20, "agent-model")
46except BudgetExhausted as exc:
47 print("blocked:", str(exc).split(":")[0])1used tokens: 250
2blocked: Token limit exceededProduction tip: When the budget approaches a configured soft threshold, stop optional exploration and compact only validated facts and pending work. A generated summary is lossy and can't replace the durable record of writes, approvals, or idempotency keys.
An agent has a 100,000-token budget and reaches 82,000 tokens before finishing. Should the system wait for the hard limit, summarize, or retry from scratch?
Answer
Stop optional exploration and compact validated context before the hard limit. The soft threshold protects latency and cost while preserving useful state. Retrying from scratch wastes work, and waiting for the hard limit risks an abrupt failure after the user has already waited.
Why can a 20-step agent run cost more than 20 separate one-step calls?
Answer
Each later step often resends much of the prior trajectory: user request, thoughts, tool calls, observations, and summaries. Prompt size grows with the run, so total input tokens can grow roughly quadratically unless the runtime summarizes or truncates state.
Cascading failures in multi-agent systems
In complex architectures, such as Directed Acyclic Graphs (DAGs) or supervisor-worker graphs, failures usually spread in two ways: a bad handoff poisons downstream reasoning, or the agent's internal state drifts away from the source of truth after a partial side effect. Both create a snowball effect where a minor mistake near the start of a pipeline compounds into a much larger failure later.
Consider an example failure chain in an incident-triage pipeline:
- Monitor Agent: "CI check passed" (Hallucinates a test result event that never happened).
- Gate Agent: "Tests passed, so approve deployment" (Treats the hallucinated check as source-of-truth state).
- Responder Agent: "Run RUN-842 passed yesterday" (Sends a confident user-facing answer grounded in false state).
A second version is even more dangerous because it looks operationally healthy. Suppose a rollback_deploy tool times out after the deployment controller already committed the rollback. If your orchestrator records that step as failed and blindly retries, the duplicate side effects leave the agent state out of sync with the external system.
Defense strategy
Use strict, per-agent output validation checks plus explicit state reconciliation around side effects. You should never assume an agent's output is safe to pass directly to another agent without verification, and you should never assume a timed-out write means "nothing happened."
Before passing context to the next node in the graph, run a validator. Format and authorization checks should be deterministic; factual operational state should be checked against its source of truth. A model-based critic can flag candidates for review, but it can't prove that a run passed or a rollback committed. If validation fails, the pipeline halts and either requests a corrected proposal or escalates. For side-effecting tool calls, add read-after-write reconciliation and idempotency keys before telling downstream agents the action succeeded.
The validated_handoff function implements this pattern to secure the boundaries between components. It accepts the upstream agent's raw output and a list of validation functions as inputs. Each validator returns a small ValidationResult, so the handoff code can return the intact output if all checks pass, or a structured error dictionary meant for retry or human escalation if any validation fails:
1from dataclasses import dataclass
2from typing import Callable
3
4@dataclass
5class ValidationResult:
6 valid: bool
7 reason: str = ""
8
9Validator = Callable[[dict[str, object]], ValidationResult]
10
11def validated_handoff(
12 output: dict[str, object],
13 validators: list[Validator],
14) -> dict[str, object]:
15 """Validate agent output before passing to next agent."""
16 for validator in validators:
17 result = validator(output)
18 if not result.valid:
19 # Option 1: Fail fast
20 # raise AgentError(result.reason)
21
22 # Option 2: Return structured error for retry
23 return {
24 "status": "validation_failed",
25 "error": result.reason,
26 "fallback": "Request human review of this output"
27 }
28 return outputExternal service failures
This bucket has two main states: transient faults (429, timeouts, brief 503s) and persistent dependency outages where the upstream still isn't healthy after the retry window. APIs go down, rate limits are hit, and databases occasionally time out. While traditional software also faces these issues, LLM agents are particularly vulnerable because they make a high volume of sequential API calls. A single user request might trigger multiple model generations, increasing the surface area for a network failure.
Many LLM APIs enforce separate request-rate and token-rate quotas (often expressed as RPM and TPM). Because an agent's context window grows with every step, late-stage agent turns consume more tokens than early ones. An agent can hit a token-rate limit in the middle of a complex reasoning chain, even when the raw request count is still within bounds.
Defense strategy
To handle transient network and rate-limit errors, use exponential backoff with jitter. A simple immediate retry will likely hit the same rate limit again, whereas exponential backoff gives the API time to recover. Jitter (adding randomness to retry delay) prevents the "thundering herd" problem where multiple stalled agents retry simultaneously. Only retry failures that are plausibly transient, such as 429, 502/503, or timeouts. Respect provider retry guidance such as Retry-After when it's available. Don't put deterministic failures like 400 schema errors or 401 auth problems on the same retry path.
Here's an example using the tenacity library in Python to wrap model calls with reliable retry logic. The call_llm_with_retry async function takes a list of conversational messages and a model string as inputs. It normalizes provider-specific transient failures into retryable exception classes, then retries with randomized exponential backoff. The call_model_api function stands in for your actual provider client:
1import logging
2import tenacity
3
4logger = logging.getLogger(__name__)
5
6class RetryableModelError(Exception):
7 pass
8
9class RateLimitExceeded(RetryableModelError):
10 pass
11
12class UpstreamTimeout(RetryableModelError):
13 pass
14
15class UpstreamUnavailable(RetryableModelError):
16 pass
17
18def normalize_provider_error(exc: Exception) -> Exception:
19 status_code = getattr(exc, "status_code", None)
20 message = str(exc).lower()
21 if status_code == 429 or "rate limit" in message:
22 return RateLimitExceeded(str(exc))
23 if isinstance(exc, TimeoutError) or "timeout" in message:
24 return UpstreamTimeout(str(exc))
25 if status_code in {502, 503, 504} or "temporarily unavailable" in message:
26 return UpstreamUnavailable(str(exc))
27 return exc
28
29@tenacity.retry(
30 stop=tenacity.stop_after_attempt(3),
31 wait=tenacity.wait_random_exponential(multiplier=1, min=1, max=30),
32 retry=tenacity.retry_if_exception_type(RetryableModelError),
33 before_sleep=lambda retry_state: logger.warning(
34 f"Retry {retry_state.attempt_number}: {retry_state.outcome.exception()}"
35 ),
36)
37async def call_llm_with_retry(messages: list[dict[str, str]], model: str = "primary-model"):
38 """Wrap a provider call with exponential backoff plus jitter."""
39 try:
40 return await call_model_api(
41 messages=messages,
42 model=model,
43 timeout_seconds=30,
44 )
45 except Exception as exc:
46 raise normalize_provider_error(exc) from excBlind retries only help with transient infrastructure faults. If the model keeps making the same schema mistake, the next attempt needs better information instead of more time. Reflexion-style recovery uses feedback from the previous trial to improve the next one.[5] In production, that feedback can be much simpler than a full reflection loop: a concise validation error or tool rejection reason is often enough to change the next attempt.
Prefer typed SDK exceptions and provider response headers in production. The normalizer above keeps a message fallback only to show the classification boundary without coupling the lesson to one SDK.
Failover must preserve the payload contract
A 429 or dead endpoint may route traffic to a backup model from the same provider or a second provider. That improves availability but creates a correctness risk: the backup may return a different structured-output or tool-call shape. Strict-schema support, JSON wrappers, field names, and function arguments vary across models and providers.
Validate primary and fallback responses against one canonical schema before dispatch. Give each route an adapter that maps its native output into that schema. Otherwise a malformed or plausible-but-wrong argument can reach a tool. If the fallback can't satisfy the contract, correct or escalate the validation failure. Never forward the payload unchanged.
Retry, resume, or roll back?
External faults are where retry logic usually starts, but the same decision tree applies everywhere. Retries are only the right tool when the failure is transient and nothing important has committed yet. A 429, 503, or dropped connection usually fits that pattern. A poisoned context or partially completed side effect doesn't. If the agent already believes "rollback queued" even though the tool call timed out, replaying the next step can duplicate production changes or reason from false state.
Durable runtimes handle this with checkpoints rather than blind replay. LangGraph persists graph state as checkpoints keyed by thread_id and resumes from a saved super-step boundary.[6] Temporal persists workflow execution state in event history and replays deterministic workflow code after failures.[7] Both patterns let you resume from the last confirmed-good boundary instead of restarting a long run from scratch.
In practice, the rule is simple:
- Retry when the failure is transient and the step is side-effect free.
- Resume from a checkpoint when prior state is still valid but expensive to recompute.
- Reconcile or roll back when the failure happened around a non-idempotent side effect such as triggering a rollback, mutating a feature flag, or posting an incident update.
This classifier makes the distinction executable. Notice that an uncertain write never enters the ordinary retry path, even when its transport error looks transient.
1def recovery_path(error: str, side_effect_status: str, checkpoint_valid: bool) -> str:
2 if side_effect_status == "uncertain":
3 return "reconcile before replay"
4 if checkpoint_valid and error == "worker_restarted":
5 return "resume checkpoint"
6 if side_effect_status == "none" and error in {"429", "503", "timeout"}:
7 return "retry with backoff"
8 return "stop or escalate"
9
10print("lookup timeout:", recovery_path("timeout", "none", False))
11print("rollback timeout:", recovery_path("timeout", "uncertain", True))1lookup timeout: retry with backoff
2rollback timeout: reconcile before replayProduction tip: Pair checkpoints with idempotency keys for outbound side effects. Resume must be safe even if the previous attempt failed after the external system committed but before your agent recorded success.

A rollback tool times out after the deployment controller may have committed the rollback. Why is blind retry wrong, and what should the orchestrator do instead?
Answer
Blind retry can issue a duplicate rollback because the timeout doesn't prove the side effect failed. The orchestrator should resume from the last confirmed checkpoint, use the rollback idempotency key, and reconcile against the deployment controller before deciding whether to retry, roll back, or continue.
Which failures are good retry candidates, and which failures need a different recovery path?
Answer
Retry transient, side-effect-free failures such as rate limits, brief 503s, and timeouts before a write commits. Use structured feedback for schema mistakes, circuit breakers for persistent dependency outages, checkpoints for long-running state, and reconciliation for possible side effects.
Action-contract disconnect
A dangerous failure happens when the agent's proposed operation diverges from its accepted task contract. The two common states are wrong tool choice despite a valid request and wrong argument serialization despite choosing the right tool. A model may describe a reasonable plan yet call the wrong tool or use unsupported parameters when it emits the executable payload.
Consider our deploy-status agent. Its structured intent says lookup_run for RUN-842. However, the emitted tool call might target a create_release tool or provide a different run ID, producing irrelevant results while the response text still sounds plausible.
This disconnect happens because generated explanations don't constrain generated tool payloads. An application should validate the payload against its task contract instead of asking for hidden reasoning or trusting a confident explanation.
Defense strategy
The primary defense is execution verification: compare the proposed tool call to a structured task contract before execution. A critic model can help triage ambiguous output, but deterministic tool, identifier, permission, and schema checks should block clear violations.
1class ActionMismatch(Exception):
2 pass
3
4def validate_action_contract(
5 task_contract: dict[str, object],
6 tool_name: str,
7 tool_args: dict[str, object],
8 allowed_tools: set[str],
9) -> str:
10 if tool_name not in allowed_tools:
11 raise ActionMismatch("tool not allowed")
12 if task_contract["intent"] == "lookup_run" and tool_name != "get_run":
13 raise ActionMismatch("tool does not match intent")
14 if tool_args.get("id") != task_contract["run_id"]:
15 raise ActionMismatch("run ID changed")
16 return "admitted"
17
18contract = {"intent": "lookup_run", "run_id": "RUN-842"}
19print("lookup:", validate_action_contract(contract, "get_run", {"id": "RUN-842"}, {"get_run"}))
20try:
21 validate_action_contract(contract, "create_release", {}, {"get_run"})
22except ActionMismatch as exc:
23 print("drift:", exc)1lookup: admitted
2drift: tool not allowedWhy is action-contract validation useful even if the model's explanation looks correct?
Answer
The explanation and tool payload are separate generated artifacts. A model can say "query the run database" while emitting a different tool or run identifier. The runtime should validate the executable payload against structured scope, not trust narration.
Self-correction with the Reflexion pattern
So far we've treated each failure as a separate problem with a separate fix. For failures that permit another attempt, structured feedback can help the model propose a better one. The Reflexion pattern, introduced by Shinn et al. (2023), gives an agent a way to reflect on feedback and refine its strategy before trying again.[5]
The loop is simple: Generate -> Critique -> Refine.
Self-critique uses a doer-critic-fixer loop. The generator produces the first attempt, the critique marks what's wrong, and the next generation addresses that feedback. In an agent, the same LLM can play those roles by switching prompts.
For the deploy-status agent, a 400 Bad Request recovery looks like this:
1[Generate] Agent: get_run({"run_id": "RUN-842"})
2[Observe] System: 400 Bad Request: Missing required parameter 'id'.
3[Critique] Critic prompt: "The agent used 'run_id' but the schema requires 'id'.
4 The agent should check the tool schema before retrying."
5[Refine] Agent: get_run({"id": "RUN-842"})
6[Observe] System: 200 OK: Status failed, failing job unit-tests.Compared with simple retry, the critique step produces new reasoning rather than a repeated attempt. The agent explicitly names what went wrong and how to fix it, which changes the probability distribution of the next generation.
Critique isn't proof: A model critique is another proposal, not proof. Use it to suggest a repair after an allowed failure, then rerun deterministic validators and source-of-truth checks before execution.
What makes Reflexion different from a normal retry?
Answer
A normal retry repeats the attempt, often with the same information. Reflexion adds an explicit critique that explains what failed and how the next attempt should change. The retry becomes generate, critique, then refine rather than "try again."
In production, you don't always need a full three-step Reflexion loop. For many failures, a single structured error message is enough. Use critique/refine for repeated or ambiguous proposals that remain safe to retry; high-stakes or potentially committed effects should stop for approval or reconciliation instead.
When is a full Reflexion loop overkill?
Answer
When the failure already has a clear deterministic fix, such as "missing required field id." A concise structured error can steer the next attempt. Critique/refine makes more sense for repeated or ambiguous proposals that are still safe to rerun; high-stakes writes need authorization or reconciliation.
The circuit breaker pattern
Borrowed from microservices architecture, the circuit breaker prevents a failing component from taking down the entire system. For agents, that usually means failing fast once a tool or model endpoint crosses a known error threshold instead of letting every request discover the outage independently.

The circuit breaker pattern operates as a state machine with three distinct states to manage failure routing:
- Closed (Normal): Requests flow through to the agent/LLM. Failures are counted.
- Open (Failing): Requests fail fast immediately. This prevents wasting tokens on a dependency known to be unhealthy.
- Half-Open (Recovery): After a timeout, allow one trial request. If it succeeds, reset to Closed. If it fails, return to Open.
The "Half-Open" state keeps agent recovery controlled. If we switched from Open back to Closed after a timeout, a still-failing API would immediately receive a flood of pending agent requests (the "thundering herd" problem), potentially triggering further rate limits or blowing through your budget before the circuit trips again. A single test request proves the service is healthy before full traffic returns.
Why should a circuit breaker use a Half-Open state instead of jumping directly from Open back to Closed after the cooldown?
Answer
Half-Open sends one probe request first. If it succeeds, traffic can resume; if it fails, the circuit reopens. Jumping straight to Closed can send many pending agent requests into an unhealthy dependency and create another outage or budget spike.
Here's a complete implementation of this state machine. The CircuitBreaker class acts as a protective wrapper, where its call method takes an arbitrary async function and its arguments as inputs. This version gates the half-open state so only one probe can run at a time. It also assigns a generation to admitted calls. Opening the circuit or starting a new half-open recovery generation invalidates completions from older generations, so an old in-flight success can't close a circuit that newer failures opened:
1import asyncio
2import time
3
4class CircuitOpenError(Exception):
5 pass
6
7class CircuitBreaker:
8 """Prevents cascading failures by halting requests to failing services."""
9 CLOSED = "closed" # Normal operation
10 OPEN = "open" # All calls fail fast
11 HALF_OPEN = "half_open" # Test with one call
12
13 def __init__(self, failure_threshold: int = 5, reset_timeout: float = 60.0):
14 self.state = self.CLOSED
15 self.failure_count = 0
16 self.failure_threshold = failure_threshold
17 self.reset_timeout = reset_timeout
18 self.last_failure_time = 0.0
19 self._lock = asyncio.Lock()
20 self._half_open_probe_in_flight = False
21 self._generation = 0
22
23 async def call(self, func, *args, **kwargs):
24 """Executes the function if the circuit is closed or half-open."""
25 async with self._lock:
26 now = time.time()
27
28 if self.state == self.OPEN:
29 if now - self.last_failure_time > self.reset_timeout:
30 self.state = self.HALF_OPEN
31 self._half_open_probe_in_flight = False
32 self._generation += 1
33 else:
34 raise CircuitOpenError("Circuit is open - failing fast")
35
36 if self.state == self.HALF_OPEN and self._half_open_probe_in_flight:
37 raise CircuitOpenError("Half-open probe already in flight")
38
39 is_half_open_probe = self.state == self.HALF_OPEN
40 if is_half_open_probe:
41 self._half_open_probe_in_flight = True
42 call_generation = self._generation
43
44 try:
45 result = await func(*args, **kwargs)
46 except Exception as exc:
47 async with self._lock:
48 if self._is_dependency_failure(exc):
49 self._on_failure(call_generation, is_half_open_probe)
50 elif call_generation == self._generation and is_half_open_probe:
51 self._half_open_probe_in_flight = False
52 raise
53
54 async with self._lock:
55 self._on_success(call_generation, is_half_open_probe)
56 return result
57
58 def _on_success(self, call_generation: int, was_half_open_probe: bool):
59 if call_generation != self._generation:
60 return
61 if self.state == self.HALF_OPEN and not was_half_open_probe:
62 return
63 self.failure_count = 0
64 self.state = self.CLOSED
65 self._half_open_probe_in_flight = False
66
67 def _on_failure(self, call_generation: int, was_half_open_probe: bool):
68 if call_generation != self._generation:
69 return
70 self.failure_count += 1
71 self.last_failure_time = time.time()
72 if was_half_open_probe or self.failure_count >= self.failure_threshold:
73 self.state = self.OPEN
74 self._half_open_probe_in_flight = False
75 self._generation += 1
76
77 def _is_dependency_failure(self, exc: Exception) -> bool:
78 # Add provider-specific 429 and 5xx exceptions in production.
79 return isinstance(exc, (TimeoutError, ConnectionError))
80
81async def verify_stale_success_cannot_close_newer_open_circuit():
82 breaker = CircuitBreaker(failure_threshold=1)
83 old_call_started = asyncio.Event()
84 release_old_call = asyncio.Event()
85
86 async def old_success():
87 old_call_started.set()
88 await release_old_call.wait()
89 return "old success"
90
91 async def newer_failure():
92 raise TimeoutError("dependency failed")
93
94 old_task = asyncio.create_task(breaker.call(old_success))
95 await old_call_started.wait()
96
97 try:
98 await breaker.call(newer_failure)
99 except TimeoutError:
100 pass
101
102 assert breaker.state == breaker.OPEN
103 release_old_call.set()
104 assert await old_task == "old success"
105 assert breaker.state == breaker.OPEN
106
107asyncio.run(verify_stale_success_cannot_close_newer_open_circuit())Count only failures that indicate dependency health, such as timeouts, connection failures, and selected 5xx responses. A 400 schema error or 401 auth failure needs correction or escalation, but it shouldn't open a dependency circuit.
The generation check handles a race that the one-probe flag alone can't prevent. Several calls may already be running while the circuit is Closed. If their newer peer failures open the circuit, a slower success from the old generation may still return its own result, but it no longer has permission to reset shared breaker state. Only a success admitted in the current Closed generation or the current Half-Open probe can close the circuit.
If you run multiple app servers, keep this state in a shared store like Redis rather than in process memory. A per-process breaker protects only the worker that observed the failures.
Why does a circuit breaker need shared state in a multi-server deployment?
Answer
If each worker keeps its own breaker state, one worker may open the circuit while others keep sending traffic to the failing dependency. Shared state lets the whole fleet fail fast consistently and prevents request volume from leaking around the breaker.
Fallback chains: graceful degradation
When the primary approach fails, fall back to cheaper but more reliable alternatives. The chain below shows the order: try the most capable path first, validate its result, then degrade only when that path fails.

This chain trades open-ended behavior for narrower, safer behavior. If the full tool-using agent fails or hits a circuit breaker, you may downgrade to a simpler agent with fewer tools, then to a direct model call with no tools, and finally to a static status message. A static fallback is deterministic, but it can't answer a live deploy or rollback question. It should state that live data is unavailable and route the user to retry or on-call escalation rather than inventing status.
The next example implements the pattern in Python. The FallbackChain class runs each strategy through its execute method, which takes the user's task string as input. It tries progressively simpler internal strategies and returns the first valid string response, or a hardcoded default if all fail:
1import logging
2
3logger = logging.getLogger(__name__)
4
5class FallbackChain:
6 """Try multiple approaches in order of capability/cost.
7
8 Note: run_agent and call_llm are illustrative functions representing
9 your underlying execution engine.
10 """
11
12 # Pre-defined responses for last-resort fallback
13 FALLBACK_RESPONSES = {
14 "greeting": "Hello! I'm currently operating in degraded mode.",
15 "status": "Live status lookup is currently unavailable, please try again later."
16 }
17 DEFAULT_RESPONSE = "Live run status is unavailable right now. Please retry or contact on-call."
18
19 async def execute(self, task: str, requires_live_data: bool = False) -> str:
20 strategies = [
21 ("primary_agent", self._full_agent_execution),
22 ("backup_agent", self._simplified_agent),
23 ("direct_generation", self._direct_llm_call),
24 ("static_fallback", self._static_fallback),
25 ]
26
27 for strategy_name, strategy in strategies:
28 try:
29 result = await strategy(task)
30 if self._validate_result(strategy_name, result, requires_live_data):
31 return result
32 logger.warning(f"Strategy {strategy_name} returned invalid result")
33 except Exception as e:
34 logger.error(f"Strategy {strategy_name} failed: {e}")
35 continue
36
37 return self.DEFAULT_RESPONSE
38
39 def _validate_result(
40 self,
41 strategy_name: str,
42 result: str,
43 requires_live_data: bool,
44 ) -> bool:
45 if not result or not result.strip():
46 return False
47 if requires_live_data and strategy_name in {"direct_generation", "static_fallback"}:
48 return False
49 return True
50
51 async def _full_agent_execution(self, task: str) -> str:
52 """Full agent with tools: most capable, most expensive."""
53 # In an implementation, return only output backed by validated tool evidence.
54 return "Full agent output"
55
56 async def _simplified_agent(self, task: str) -> str:
57 """Simplified agent: fewer tools, lower step limit."""
58 # In an implementation, retain the same evidence checks as primary path.
59 return "Simplified agent output"
60
61 async def _direct_llm_call(self, task: str) -> str:
62 """Direct LLM call without tools: cheapest, fastest."""
63 # return await call_llm(task)
64 return "Direct LLM output"
65
66 async def _static_fallback(self, task: str) -> str:
67 """Static response: deterministic last-resort path."""
68 # classify_task is an illustrative function
69 # task_type = classify_task(task)
70 task_type = "unknown"
71 return self.FALLBACK_RESPONSES.get(task_type, self.DEFAULT_RESPONSE)In the fallback chain, why should the system return the first response that passes validation instead of trying every lower-capability strategy too?
Answer
Once a response is valid, more attempts add latency, cost, and new failure chances without improving reliability. The chain should degrade only as needed: primary agent, backup agent, direct model, then static response.
What should each fallback stage validate before returning to the user?
Answer
At minimum: non-empty output, safe content, required evidence present, no unsupported claims, and clear degraded-mode disclosure. A direct-model or static path must not answer a question that requires unavailable live data.
The pinned constraints pattern
Long-context studies show a clear positional bias: models often do best when relevant information appears near the beginning or end of the context window, and noticeably worse when that information is buried in the middle.[8] In long-running agent traces, critical constraints can drift into that low-salience middle region as more tool output and scratchpad text accumulate.
The pinned constraints pattern addresses this by systematically re-injecting a very short set of essential rules at the end of every prompt. This takes advantage of recency effects to keep high-priority instructions salient regardless of conversation length. It's a recall aid, not an enforcement boundary. Authorization checks, tool allowlists, and output validation still need to live outside the model.
For example, if an agent must never disclose sensitive secret data, stating this constraint once at the start of a long prompt isn't a reliable enforcement strategy. As context grows, recall of an earlier instruction can become less reliable. By appending a "pinned constraints" section to every prompt generation, you keep critical reminders near the current task.
1class PinnedConstraintsAgent:
2 """Agent that re-injects critical constraints at the end of every prompt."""
3
4 CRITICAL_CONSTRAINTS = """
5CRITICAL CONSTRAINTS (must follow):
61. Never disclose PII (Personally Identifiable Information)
72. Always confirm actions that modify data
83. If uncertain, ask for clarification rather than guessing
94. Maximum 10 tool calls per user request
10"""
11
12 def __init__(self, base_system_prompt: str):
13 self.base_system_prompt = base_system_prompt
14
15 def build_prompt(self, conversation_history: list, current_task: str) -> str:
16 """Build prompt with pinned constraints at the end."""
17 prompt_parts = [
18 self.base_system_prompt,
19 "",
20 "=== CONVERSATION HISTORY ===",
21 self._format_history(conversation_history),
22 "",
23 "=== CURRENT TASK ===",
24 current_task,
25 "",
26 self.CRITICAL_CONSTRAINTS, # Pinned at the end for recency bias
27 ]
28 return "\n".join(prompt_parts)
29
30 def _format_history(self, history: list) -> str:
31 return "\n".join([f"{msg['role']}: {msg['content']}" for msg in history[-10:]])Production tip: Keep pinned constraints concise (3-5 bullet points max). Too many constraints dilute the recency effect. Prioritize safety, compliance, and task-critical rules only.
Pinned constraints say "Never disclose PII." Is that enough to enforce privacy?
Answer
No. Pinned constraints help recall by keeping rules near the end of the prompt, but they aren't a security boundary. Privacy still needs deterministic checks outside the model, such as authorization, tool allowlists, redaction, and output validation.
Which constraints belong in a pinned block, and which belong in code?
Answer
Short reminders that guide model behavior belong in the pinned block: ask before modifying data, don't guess, respect max tool calls. Hard guarantees belong in code: authorization, PII redaction, tool allowlists, budget caps, and final output validation.
Human-in-the-loop escalation
Not all failures can be resolved automatically. When an agent encounters high-stakes ambiguity, repeated failures, or potential safety issues, it should pause and route a bounded review packet to an authorized human reviewer.
Human-in-the-loop (HITL) architectures can intercept risky proposals when triggers are correctly enforced. They don't guarantee correctness: reviewers can be wrong, approvals can become stale, and execution can drift from the reviewed proposal. Use measurable escalation triggers and, for any later side effect, bind approval to the exact action and revalidate it at execution time.
When to escalate
Escalation should be triggered by specific, measurable conditions rather than vague "uncertainty." Effective triggers include:
- Verifier thresholds: When an evaluated classifier or policy score crosses a documented review threshold
- Repeated failure patterns: After 3 consecutive tool call failures or validation rejections
- Safety-critical actions: Before executing any operation that changes user data, moves money, or accesses sensitive systems
- Flagged uncertainty: When a verifier or policy gate flags output as requiring review
Here's an implementation that demonstrates intelligent escalation logic:
1from dataclasses import dataclass
2from enum import Enum
3
4class EscalationReason(Enum):
5 POLICY_REVIEW = "policy_review"
6 REPEATED_FAILURES = "repeated_failures"
7 SAFETY_CHECK = "safety_check"
8 VALIDATION_FAILED = "validation_failed"
9 MAX_STEPS_EXCEEDED = "max_steps_exceeded"
10
11@dataclass
12class EscalationRequest:
13 reason: EscalationReason
14 context: dict[str, object]
15 priority: int # 1 (critical) to 5 (low)
16 suggested_action: str
17
18class HumanInTheLoop:
19 """Manages escalation to human operators with context preservation."""
20
21 def __init__(self,
22 review_threshold: float = 0.7,
23 max_retries: int = 3):
24 self.review_threshold = review_threshold
25 self.max_retries = max_retries
26 self.escalation_queue: list[EscalationRequest] = []
27
28 def should_escalate(self,
29 agent_state: dict[str, object],
30 failure_count: int = 0,
31 policy_score: float | None = None) -> EscalationRequest | None:
32 """Determine if human intervention is needed."""
33
34 # Safety-critical actions always receive the highest-priority review.
35 if agent_state.get("action_type") in ["delete", "modify", "transfer", "rollback"]:
36 return EscalationRequest(
37 reason=EscalationReason.SAFETY_CHECK,
38 context=agent_state,
39 priority=1,
40 suggested_action="Approve sensitive action before execution"
41 )
42
43 # Check repeated failures
44 if failure_count >= self.max_retries:
45 return EscalationRequest(
46 reason=EscalationReason.REPEATED_FAILURES,
47 context=agent_state,
48 priority=2,
49 suggested_action="Review failed attempts and provide guidance"
50 )
51
52 # Check evaluated policy threshold
53 if policy_score is not None and policy_score < self.review_threshold:
54 return EscalationRequest(
55 reason=EscalationReason.POLICY_REVIEW,
56 context=agent_state,
57 priority=3,
58 suggested_action="Review policy-flagged output"
59 )
60
61 return None # No escalation needed
62
63 async def handle_escalation(self, request: EscalationRequest) -> dict[str, object]:
64 """Submit to human review queue and await resolution."""
65 self.escalation_queue.append(request)
66
67 # In production, this would notify via Slack, PagerDuty, etc.
68 # and wait for human response
69 return {
70 "status": "escalated",
71 "ticket_id": f"ESC-{len(self.escalation_queue)}",
72 "reason": request.reason.value,
73 "priority": request.priority,
74 "review_decision": await self._await_human_input(request)
75 }
76
77 async def _await_human_input(self, request: EscalationRequest) -> dict[str, str]:
78 # Placeholder - production would integrate with ticketing system
79 return {"decision": "pending", "action_hash": "not-approved"}Escalation beats false success: An agent that says "I need help" is better than one that silently hallucinates a "Success" message. Design your escalation UX to make it clear when and why the agent failed, preserving user trust.
Name two concrete triggers that should escalate an agent run to a human reviewer.
Answer
Good triggers include repeated validation failures, a documented policy-score threshold, a proposed action that changes user data or moves money, max-step exhaustion, or a circuit breaker opening on an important dependency. The trigger should be measurable, not raw model confidence.
What context should an escalation request preserve for the human reviewer?
Answer
It should include user request, current goal, last valid checkpoint, exact proposed action or action hash, failure history, redacted tool evidence, validation errors, source-of-truth links, and why automation stopped. The reviewer needs bounded context to decide, not a raw transcript dump.
Monitoring and alerting for agent systems
Production agent systems need specialized observability beyond standard APM (Application Performance Monitoring). While latency and error rates are important, they don't capture the unique failure modes of probabilistic agents. A "healthy" agent might return 200 OK status codes while being completely stuck in a logic loop or hallucinating facts. Benchmarks such as AgentBench expose how difficult realistic multi-step agent tasks remain for strong models.[9]
Track agent-specific metrics to detect runs that are technically returning responses but failing to deliver valid results. Semantic entropy techniques measure uncertainty across sampled answers and can help prioritize review for open-ended generations; they don't verify a tool result or replace source-of-truth checks.[10]
Agent observability metrics
| Metric | Type | What it Measures | Critical Alert Threshold |
|---|---|---|---|
| Loop Rate | Derived ratio | Percentage of traces where loop detection triggered | > 5% of requests |
| Recovery Rate | Derived ratio | Percentage of failed trajectories rescued by validation, retry, fallback, or escalation | Drops below baseline |
| Fallback Rate | Derived ratio | Percentage of requests downgraded to simpler models | > 10% of requests |
| Step Count | Histogram | Number of tool calls per user turn | P99 (99th percentile) > 15 steps |
| Token Usage | Histogram | Total tokens (prompt + completion) per turn | > 80% of budget |
| Validation Rejection Rate | Derived ratio | Percentage of validated outputs rejected by factual or schema checks | > 2% of validated outputs |
Counters track cumulative events like loops, fallbacks, and validation failures. Histograms record distributions like step counts and token usage. Validation rejection rate and recovery rate are usually computed as derived ratios from validation and incident counters rather than stored directly as gauges. A useful definition is:
For example, if 100 deploy-status traces hit a validation error, loop breaker, timeout, or fallback path, and 72 still return a safe answer or clean escalation, the recovery rate is 72%. This metric tells you whether your defenses are helping users instead of only detecting failures.
Eval hygiene for recovery systems
Offline harnesses measure recovery, but they can also leak gold into the agent. Treat expected trajectories, gold tool results, and grader labels as eval-only artifacts:
- Pin fixtures and keep them outside the agent's tool, memory, and prompt paths during measurement.
- Never write gold answers or expected tool traces into episodic or archival memory from the harness.
- Feedback channels used in eval must match production. No hidden oracle tools that only exist in the test loop.
- If recovery rate jumps after a fixture change, report possible contamination before celebrating the metric.
A recovery system that "learns" the grader isn't recovering; it's cheating the measurement.

Why is recovery rate more useful than validation-failure count alone?
Answer
Validation-failure count tells you how often agents trip a guard. Recovery rate tells you whether the guard leads to a safe answer, clean fallback, or proper escalation. A system that detects many failures but strands users still needs work.
The Prometheus client can define custom agent metrics directly. AgentMetrics sets up counters and histograms that map cleanly to the table above. Once instantiated, it produces metric objects that take labels, such as the agent name or model, so your observability stack can monitor loop rates, fallback frequency, and validation failures over time:
1from prometheus_client import Counter, Histogram
2
3class AgentMetrics:
4 """Tracks specialized observability metrics for LLM agents."""
5 def __init__(self):
6 # Per-run distributions support P95/P99 alerting
7 self.step_count = Histogram(
8 "agent_steps_per_run",
9 "Tool calls per agent execution",
10 ["agent"],
11 buckets=(1, 3, 5, 8, 10, 15, 20, 30),
12 )
13
14 # Event counters
15 self.loop_detected = Counter("agent_loops_total", "Loop detections", ["agent"])
16 self.fallback_triggered = Counter("agent_fallbacks_total", "Fallback triggers", ["strategy"])
17 self.interventions_triggered = Counter(
18 "agent_interventions_total",
19 "Failure-handling interventions triggered",
20 ["agent", "intervention"],
21 )
22 self.safe_recoveries = Counter(
23 "agent_safe_recoveries_total",
24 "Interventions that ended in safe answer or clean escalation",
25 ["agent", "intervention"],
26 )
27 self.validation_failures = Counter(
28 "agent_validation_failures_total",
29 "Outputs rejected by validation checks",
30 ["agent", "reason"],
31 )
32 self.validated_outputs = Counter(
33 "agent_validated_outputs_total",
34 "Outputs checked by validation checks",
35 ["agent"],
36 )
37
38 # Budget distributions
39 self.token_usage = Histogram("agent_tokens_per_run", "Tokens per execution", ["model"])Critical alerts
Treat these numbers as example starting points calibrated from your own baseline, not universal constants.
- Loop Detection Rate > 5%: A high loop rate indicates the agent's prompt or tools are ambiguous, causing it to oscillate. Treat it as a code/prompt issue, not an infrastructure issue.
- Fallback Rate > 10%: If the system frequently downgrades to simpler models or static responses, a primary dependency, model path, verifier, or task mix may have changed; inspect traces before assigning cause.
- P99 Token Usage > Budget: If the 99th percentile of requests hits the token limit, you need summarization or a tighter sliding context window before hard stops start landing on real users.
- Circuit Breaker OPEN: The configured failure threshold was crossed. Investigate the dependency, client, network path, timeout budget, and breaker sensitivity before assigning the cause.
These agent-specific metrics should be piped directly into your alerting infrastructure (like PagerDuty or Datadog) alongside standard server metrics. Catching a spike in loop detections early can be the difference between a minor service degradation and a massive, unexpected API bill at the end of the month.
Common misconceptions
When engineers transition from deterministic software to building agentic pipelines, they often carry over assumptions that don't apply to probabilistic models. Recognizing and unlearning these patterns helps teams build resilient systems.
Common mistake: Believing you can "just retry on failure" like traditional APIs. Reality: Retrying the same hallucinating LLM call often repeats the same failure mode, or produces a different wrong answer. Effective recovery requires changing the strategy, either by modifying the prompt, switching models, or reducing the task scope.
Common mistake: Treating every timeout as safe to replay. Reality: If a side effect may already have committed, replay can duplicate work or create inconsistent state. Use idempotency keys, checkpoints, and explicit reconciliation against the source of truth.
Common mistake: Assuming that max step counts are enough to prevent loops. Reality: Step limits are a crude safety net. They don't prevent an agent from wasting 15 steps (and thousands of tokens) on a futile loop before hitting the limit. Semantic loop detection stops the bleeding early.
Common mistake: Wrapping all agent calls in generic try/except blocks. Reality: Catching all exceptions silently leads to "zombie agents" that continue operating with corrupted state. Failures should be explicit and handled by validation checks or circuit breakers, not swallowed by generic error handlers.
Practice: diagnose the failure
Here's a short exercise to test whether you can match symptoms to defenses. Read each scenario, decide which failure category it belongs to, and pick the right fix.
-
Scenario 1: Your deploy-status agent calls
get_run({"id": "RUN-842"})three times in a row, gets the same answer each time, and keeps going. -
Scenario 2: The agent's reasoning trace says "I will search the database" but the actual tool call is
create_release({}). -
Scenario 3: After a rollback tool times out, the orchestrator retries and the developer receives two rollback notifications.
-
Scenario 4: The agent has used 120,000 tokens on a single request and your limit is 100,000.
-
Scenario 5: The agent invents a tool called
fetch_run_secretthat doesn't exist in your tool registry.
Solution sketches
-
Scenario 1: Infinite loop (Planning failure). The
LoopBreakerwould catch it on the third identical call viaargs_hashdeduplication. You should also check why the agent isn't stopping after getting a complete answer. Is the stopping condition too vague? -
Scenario 2: Action-contract disconnect (Action failure). The runtime should compare the emitted tool to the structured task contract and allowlist, blocking
create_releasewhen the accepted intent is deploy status. A critique model can add review signal, but it shouldn't replace deterministic capability checks. -
Scenario 3: Cascading failure (Memory/State failure). The rollback was non-idempotent and the timeout didn't mean "nothing happened." You need idempotency keys on the rollback tool and read-after-write reconciliation before retrying. Blind replay caused a duplicate side effect.
-
Scenario 4: Token budget exhaustion (Memory failure). The
TokenBudgetclass would raiseBudgetExhaustedat the hard limit. Better yet, you should trigger summarization at the 80% soft threshold to prevent hitting the hard limit at all. -
Scenario 5: Tool call hallucination (Planning failure). A tool allowlist would reject
fetch_run_secretbefore execution, returning structured feedback:Tool 'fetch_run_secret' not available. Available tools: get_run, get_logs, suggest_alternative.
Which scenario is most dangerous financially, and why?
Answer
Scenario 3 is most dangerous because it can duplicate a real rollback. Loops and hallucinated tools burn tokens, but replaying a timed-out non-idempotent side effect can directly mutate production state and corrupt the run history.
Agent failure controls
- Small per-step errors compound. A task of
ndependent steps succeeds with roughlyp^n, so a 95%-reliable step is nearly useless over a 50-step run. Reliability, not raw reasoning, is usually the bottleneck. - Generated failures can look successful. Build validation checks for claims and actions, not exception handlers alone.
- The four failure categories (Planning, Action, Reflection, Memory) collapse into six concrete states you'll see again and again: tool misuse, loops, budget exhaustion, poisoned handoffs/state desync, external dependency faults, and action-contract drift.
- Loop detection needs multiple signals: exact-call hashing, approximate intent similarity, repeated-tool heuristics, step limits, and token budgets.
- Corrective feedback beats blind retry for deterministic mistakes. Reflexion-style critique can help ambiguous failures, but it doesn't authorize actions.
- Circuit breakers limit repeated calls to unhealthy dependencies; they don't establish correctness.
- Fallback chains degrade capability explicitly. Live-data questions must stop or escalate when fallback paths lack evidence.
- Checkpointed recovery beats blind replay when state may already be poisoned or partially committed.
- Pinned constraints exploit recency bias to keep must-follow rules salient in long conversations.
- Human review must be bound to exact actions and revalidated at execution; escalation alone isn't a guarantee.
- Agent-specific metrics such as loop rate, fallback rate, and token usage matter as much as latency and error rate.
Handoff to orchestration
Single-agent failure handling should feel concrete: detection, classification, retry, fallback, review, and recovery all have named contracts. Orchestration extends that discipline across planning, retrieval, execution, review, and escalation; every worker needs an owner, a handoff contract, and a shared view of state.