Learn how to implement validation checks, retries, checkpointed recovery, state reconciliation, loop breakers, and graceful degradation when LLM agents hallucinate, stall, or drift from their tools.
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.
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.
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.
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:
get_run with {"id": "RUN-842"}.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:
If those concepts are fuzzy, revisit them first. The recovery patterns below assume that foundation.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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 outputThis 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.
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.
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.
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:
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 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.
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 allowedSo 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.
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.
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:
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.
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.
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)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.
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.
Escalation should be triggered by specific, measurable conditions rather than vague "uncertainty." Effective triggers include:
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.
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]
| 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.
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"])Treat these numbers as example starting points calibrated from your own baseline, not universal constants.
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.
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.
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_secret that doesn't exist in your tool registry.
Scenario 1: Infinite loop (Planning failure). The LoopBreaker would catch it on the third identical call via args_hash deduplication. 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_release when 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 TokenBudget class would raise BudgetExhausted at 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_secret before execution, returning structured feedback: Tool 'fetch_run_secret' not available. Available tools: get_run, get_logs, suggest_alternative.
n dependent steps succeeds with roughly p^n, so a 95%-reliable step is nearly useless over a 50-step run. Reliability, not raw reasoning, is usually the bottleneck.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.
429, timeouts, and short outages; route schema, auth, and policy failures to correction or escalation.Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
ReAct: Synergizing Reasoning and Acting in Language Models.
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K., & Cao, Y. · 2023 · ICLR 2023
Toolformer: Language Models Can Teach Themselves to Use Tools.
Schick, T., et al. · 2023 · NeurIPS 2023
Measuring AI Ability to Complete Long Tasks
Kwa, T., West, B., Becker, J., et al. (METR) · 2025
Tau-Bench: A Benchmark for Tool-Agent-User Interaction in Real-World Domains
Yao, S., et al. · 2024 · arXiv preprint
Reflexion: Language Agents with Verbal Reinforcement Learning.
Shinn, N., et al. · 2023
LangGraph Persistence
LangChain · 2026
Temporal Workflow Execution Overview
Temporal Technologies · 2026
Lost in the Middle: How Language Models Use Long Contexts
Liu, N.F., et al. · 2023 · TACL 2023
AgentBench: Evaluating LLMs as Agents
Liu, X., et al. · 2023
Detecting Hallucinations in Large Language Models Using Semantic Entropy
Farquhar, S., et al. · 2024 · Nature
Questions and insights from fellow learners.