Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A deploy-status agent gets one plain question: "What happened to run RUN-842?" It should look up a CI (continuous integration) run, read the failing job, and give you a short summary. Instead, it might call a tool that doesn't exist, send the wrong field name, read the same result until its budget runs out, or say a rollback succeeded after the write timed out. The response can still sound calm and complete.
Agent memory can carry scoped context across that run, but it can't decide whether a recalled note is still true, whether a timed-out write committed, or whether a generated tool call is safe. An agent runtime is the trusted loop around the model: it validates proposals, executes allowed tools, records observations, and checks claims before they leave the system. ReAct interleaved reasoning and actions,[1] while Toolformer explored learned API use.[2] Neither makes those runtime checks optional.
We'll stay with RUN-842 and follow one failure at a time. Start by separating a plausible answer from verified state, then add validation, retry policy, circuit breakers, checkpointed resume, and bounded fallback. Success isn't "never fail." It means keeping failure visible, bounded, and reversible before a generated claim or an uncertain write reaches 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
Once a model can choose tools, tests alone can't police every generated path. Ask what must be checked while RUN-842 is in flight. The comparison makes the extra runtime boundary visible:
| 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 |
The extra risk comes from generated actions and answers. A small input or sampling change can make the same agent choose a different tool or interpret the same observation as success. Boundary checks catch known errors; runtime signals also need to catch a trajectory that is quietly getting worse.
You can't unit-test every conversational path. Offline eval harnesses still matter, but they miss generated branches you didn't sample. Runtime guardrails fill that gap with schema checks, allowlists, step limits, loop detection, budgets, verifiers, and side-effect reconciliation. When the model drifts, the surrounding system either steers it back or stops before the next untrusted write.
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
Put numbers on the risk before naming the equation. A task with 40 required steps can succeed less than half the time when each step succeeds 98% of the time. In the toy model, every step is required and the steps are independent, so task success is:
Here, p is the success probability for one step and n is the number of required steps. The product decays fast, even when each individual step looks reliable:
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 become nearly useless in this 50-step model. Real failures are rarely independent or equally likely. One expired credential can break every later call, while a checkpoint can make some failures recoverable. Treat the equation as a warning about long chains, not as a production reliability estimator.
The toy model isn't a benchmark. METR's March 2025 task-horizon analysis measured the length of software tasks agents could complete at a given reliability level. On that suite, Claude 3.7 Sonnet reached roughly one hour of human task time at 50% reliability.[3] That's a dated snapshot of one model on one benchmark, not a per-step estimate or a current leaderboard claim. It still shows why a capability demo and a dependable long-running workflow are different engineering targets.
The -bench paper gives the same warning from another angle. Its retail and airline tasks involve an agent, a user, and tools. GPT-4o got below 25% pass^8 in retail: fewer than a quarter of tasks succeeded on all eight sampled trials.[4] A system can look capable on one run and still be inconsistent across repeats.
Every defense in this lesson attacks that product in one of two ways. It can raise per-step reliability p with validation, action contracts, or structured feedback. Or it can shrink the number of unrecovered steps with retries, fallbacks, checkpoints, loop breakers, and escalation. You can't make p = 1, so 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
The equation explains why a long trajectory is fragile. Now trace one short task so each boundary has a concrete place in the story. The ReAct agent loop supplies the plan-act-observe pattern, and function calling lets a model propose a call against a tool schema.
A single ReAct-style agent receives a developer question, plans a tool call, executes it, and observes the result:
- 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."
That happy path hides three separate decisions: choose a registered tool, serialize arguments that satisfy its contract, and stop only after verified evidence arrives. Any one can fail. The sections that follow keep the same run ID while adding one symptom and one defense at a time.
Classify failure before choosing recovery
Recovery follows the boundary that failed, not a label such as "the model was wrong." A timeout shows why labels aren't enough: a read may be safe to retry, while a write must be reconciled before any replay. Use the observable evidence to choose the first safe response:
| Failed boundary | Observable evidence | Safe first response |
|---|---|---|
| Proposal | Unknown tool, invalid arguments, forbidden action | Reject before execution; return structured correction feedback |
| Dependency | Timeout, rate limit, or temporary service failure | Retry only if the fault is transient and the operation is safe to repeat |
| External state | Timeout after a write; local state disagrees with source of truth | Reconcile using an operation ID or idempotency key before replay |
| Progress | Repeated call, repeated intent, or exhausted budget | Stop, narrow capabilities, or escalate with trace evidence |
| Result | Empty, stale, or unsupported answer | Validate evidence; degrade explicitly rather than inventing an answer |
Read the table as an incident triage order. First ask whether a side effect may already have happened. If not, classify the dependency fault, then look for a trusted checkpoint before deciding to stop or escalate.

Recovery choice depends on side-effect uncertainty, fault class, and checkpoint validity. Retry is only one branch.
A deploy agent says "deployment approved" because an earlier CI pass was hallucinated. Which boundary failed, and what should catch it before the reply reaches a user?
Answer
The result boundary failed because the answer lacked verified evidence; persisted false context may also have contaminated state. Validate the CI handoff against source-of-truth data before downstream use. If any write has uncertain status, reconcile it with an idempotency key before replay.
Worked example: the wrong parameter
Start with a malformed proposal that never reaches external state. get_run expects {"id": "RUN-842"}, but the deploy-status agent sends {"run_id": "RUN-842"} instead. The API rejects it with a 400 Bad Request: Missing required parameter 'id'.
A naive agent sees the error and sends the exact same call again. Nothing changed, so it gets the same 400. That's the "just try again" anti-pattern: without a reason it can act on, the agent has no basis for changing its proposal.
Feed the error back as structured feedback rather than a raw exception string. The agent can compare its proposal with the tool schema, correct the key name, and try again. This is a repair retry: validation supplies new information before another model call. It doesn't require the longer-lived episodic reflection used by Reflexion, which we revisit later.
The corrected trace is short enough to inspect by hand:
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.
Validate arguments before execution
Strict JSON schema validation gives this failure a clean boundary. Model APIs often support tool calling or structured outputs, but you still validate arguments before execution and apply authorization separately. Production code often uses Pydantic or Zod. The standard-library function below makes the contract visible: get_run accepts only {"id": "RUN-842"}, rejects unknown keys, and returns a one-line correction instead of executing.
1import re
2
3class ToolCallError(Exception):
4 pass
5
6RUN_ID = re.compile(r"^RUN-[0-9]{3,}$")
7
8def validate_tool_call(raw_args: dict[str, object]) -> dict[str, str]:
9 unknown = [key for key in raw_args if key != "id"]
10 if unknown:
11 raise ToolCallError("get_run requires valid field: id")
12 run_id = raw_args.get("id")
13 if not isinstance(run_id, str) or not RUN_ID.fullmatch(run_id):
14 raise ToolCallError("get_run requires valid field: id")
15 return {"id": run_id}
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. Stack traces are noisy, and the model will often retry the same mistake after skimming them. Agentic debugging means sending the smallest root-cause sentence that changes the next proposal, not a 50-line 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
Shape validation still isn't enough. A payload can be well-formed and name a tool that no runtime has registered. Tool misuse has two concrete forms: the model invents a tool, or it emits malformed arguments for a real one. When context is incomplete, a plausible-sounding name can replace a request 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"}}search_runs sounds reasonable, but it isn't in the registry. Without an allowlist check, the runtime either sends the call to a nonexistent endpoint or lets the model infer a nearby capability. Either response creates an error the agent may misread.
Reject calls outside the tool registry
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")
Fixing one proposal doesn't guarantee progress. A valid call can repeat forever, either exactly or with new wording. 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)The missing ingredient is an explicit progress signal. A vague stopping condition, weak error feedback, or absent state check lets the model propose the same approach with slightly different wording. The model isn't required to prove that its new action differs from the failed one, so the runtime has to detect repetition.
Detect repeated actions before the hard cap
A step cap is too late. LoopBreaker.check looks at a proposed tool name and arguments before the call runs. It combines exact-call hashes, same-tool streaks, and approximate intent similarity, then returns True when the next call would cross 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 if len(self.tool_call_history) >= self.max_steps:
56 return True
57
58 identical_attempts = 1 + sum(
59 1 for h in self.tool_call_history if h["args_hash"] == call["args_hash"]
60 )
61 if identical_attempts >= self.max_identical:
62 return True
63
64 same_tool_attempts = 1
65 for h in reversed(self.tool_call_history):
66 if h["tool"] != tool_name:
67 break
68 same_tool_attempts += 1
69 if same_tool_attempts >= self.same_tool_streak:
70 return True
71
72 recent_window = self.tool_call_history[-(self.same_tool_streak - 1):]
73 similar_attempts = 1 + sum(
74 1
75 for h in recent_window
76 if h["intent_text"]
77 and difflib.SequenceMatcher(
78 None, h["intent_text"], call["intent_text"]
79 ).ratio() >= self.semantic_threshold
80 )
81 if similar_attempts >= self.max_identical:
82 return True
83
84 # Only append after checks to avoid polluting history with the failing call
85 self.tool_call_history.append(call)
86 return False
87
88breaker = LoopBreaker(max_identical=3)
89print("first:", breaker.check("get_run", {"id": "RUN-842"}))
90print("second:", breaker.check("get_run", {"id": "RUN-842"}))
91print("third:", breaker.check("get_run", {"id": "RUN-842"}))
92
93cross_tool = LoopBreaker(max_identical=2)
94print("cross-tool first:", cross_tool.check("get_run", {"id": "RUN-842"}))
95print("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: TrueThe thresholds include the current attempt. That detail matters: an off-by-one bug burns one more 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
A loop can stop making progress before it hits the hard step cap, while still consuming context and money. Consider a 20-step task whose runtime appends every tool call and observation to one transcript. Each prompt grows as history accumulates, and later turns resend most of what came before. The final step includes the previous 19 steps, so total input spend can grow roughly quadratically rather than like 20 isolated calls.
Left unchecked, that growth can overflow the context window, trigger provider rejection or truncation, and inflate the API bill. A subtle loop can burn through tokens long before an operator sees a clear error.
Use soft and hard budgets
Treat budget as two layers. A hard limit is a circuit breaker: if tokens or estimated cost cross the cap, the run stops. Soft management watches the window earlier and compact validated state before that stop is the only option.
TokenBudget.consume records the input and output tokens from a completed generation, then raises BudgetExhausted if a cap is crossed. Pair that post-call accounting with a preflight context estimate and a 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
Budgets bound one run. A multi-agent graph adds another risk: a bad handoff can turn one local mistake into a chain of confident decisions. In a supervisor-worker DAG (directed acyclic graph), failures usually spread because later nodes trust unsupported output or because local state drifts from the source of truth after a partial side effect.
Here's a failure chain on RUN-842:
- Monitor agent: "CI check passed" (invents a test result that never happened).
- Gate agent: "Tests passed, so approve deployment" (treats the invented 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 worse because it looks operationally healthy. Suppose rollback_deploy times out after the deployment controller already committed the rollback. If the orchestrator records that step as failed and retries, the duplicate side effect leaves agent state out of sync with production.
Validate handoffs and reconcile writes
Pass agent output across a boundary only after verification, and never treat a timed-out write as "nothing happened."
Format and authorization checks should be deterministic. Operational facts should be checked against their source of truth. A model-based critic can flag candidates for review, but it can't prove that RUN-842 passed or that a rollback committed. If validation fails, halt and either request a corrected proposal or escalate. For side-effecting calls, add read-after-write reconciliation and idempotency keys before telling downstream agents the action succeeded.
validated_handoff runs those checks. Each validator returns a ValidationResult. If every check passes, the original output continues. If any check fails, the function returns a structured error for retry or review instead of poisoning the gate agent.
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 it to the next agent."""
16 for validator in validators:
17 result = validator(output)
18 if not result.valid:
19 return {
20 "status": "validation_failed",
21 "error": result.reason,
22 "fallback": "Request human review of this output",
23 }
24 return output
25
26def require_ci_evidence(output: dict[str, object]) -> ValidationResult:
27 if output.get("claim") == "ci_passed" and output.get("ci_status") != "passed":
28 return ValidationResult(False, "CI claim lacks source-of-truth evidence")
29 return ValidationResult(True)
30
31blocked = validated_handoff(
32 {"claim": "ci_passed", "run_id": "RUN-842", "ci_status": "missing"},
33 [require_ci_evidence],
34)
35print(blocked["status"], blocked["error"])
36passed = validated_handoff(
37 {"claim": "ci_passed", "run_id": "RUN-842", "ci_status": "passed"},
38 [require_ci_evidence],
39)
40print("handoff:", passed["claim"])1validation_failed CI claim lacks source-of-truth evidence
2handoff: ci_passedExternal service failures
External faults split into transient failures (429, timeouts, brief 503s) and outages that persist after the retry window. An agent feels the difference more sharply than a single-request client because one user question can fan out into several model and tool calls. Late turns also carry more context, so a run can hit a token-per-minute quota even when request count looks normal.
Retry only classified transient faults
Before adding a retry, ask two questions: is the fault transient, and is the operation safe to repeat? For transient network and rate-limit errors, use exponential backoff with jitter. Immediate retries tend to hit the same overload; jitter spreads attempts across time instead of letting a fleet retry in lockstep.[5]
Amazon Web Services (AWS)'s "full jitter" form sleeps a random time in [0, min(cap, base * 2**(attempt-1))]. A 429, 502/503, or timeout is only a retry candidate: provider guidance must permit the retry, and the operation must be safe to repeat. Respect Retry-After when available. Keep a 400 schema error or 401 auth failure off this path.
The retry helper classifies those faults, then retries with full jitter. Its fake clock exposes two retries without making you wait on real sleeps. Libraries such as Tenacity wrap the same idea; the point is the classification boundary, not the decorator.
1import random
2
3class RetryableModelError(Exception):
4 pass
5
6class RateLimitExceeded(RetryableModelError):
7 pass
8
9class UpstreamTimeout(RetryableModelError):
10 pass
11
12class UpstreamUnavailable(RetryableModelError):
13 pass
14
15def normalize_provider_error(exc: Exception) -> Exception:
16 status_code = getattr(exc, "status_code", None)
17 message = str(exc).lower()
18 if status_code == 429 or "rate limit" in message:
19 return RateLimitExceeded(str(exc))
20 if isinstance(exc, TimeoutError) or "timeout" in message:
21 return UpstreamTimeout(str(exc))
22 if status_code in {502, 503, 504} or "temporarily unavailable" in message:
23 return UpstreamUnavailable(str(exc))
24 return exc
25
26def call_llm_with_retry(
27 call,
28 *,
29 attempts: int = 3,
30 base: float = 1.0,
31 cap: float = 30.0,
32 rng: random.Random | None = None,
33 sleep=lambda _seconds: None,
34):
35 """Retry transient faults with full jitter. Deterministic failures raise immediately."""
36 rng = rng or random.Random(0)
37 last_error: Exception | None = None
38 for attempt in range(1, attempts + 1):
39 try:
40 return call()
41 except Exception as exc:
42 normalized = normalize_provider_error(exc)
43 last_error = normalized
44 retryable = isinstance(normalized, RetryableModelError)
45 if not retryable or attempt == attempts:
46 raise normalized from exc
47 delay = min(cap, base * (2 ** (attempt - 1)))
48 sleep(rng.uniform(0, delay))
49 raise last_error or RuntimeError("retry loop exited without a result")
50
51class FakeClock:
52 def __init__(self) -> None:
53 self.slept: list[float] = []
54
55 def sleep(self, seconds: float) -> None:
56 self.slept.append(seconds)
57
58def make_flaky(failures_before_success: int):
59 remaining = {"n": failures_before_success}
60
61 def call() -> str:
62 if remaining["n"] > 0:
63 remaining["n"] -= 1
64 exc = Exception("rate limit")
65 exc.status_code = 429
66 raise exc
67 return "RUN-842 failed in unit-tests"
68
69 return call
70
71clock = FakeClock()
72result = call_llm_with_retry(
73 make_flaky(2),
74 attempts=3,
75 rng=random.Random(1),
76 sleep=clock.sleep,
77)
78print("result:", result)
79print("retries:", len(clock.slept))1result: RUN-842 failed in unit-tests
2retries: 2Notice what this helper doesn't repair. If the model keeps making the same schema mistake, the next attempt needs better information instead of more time. A concise validation error or tool-rejection reason is usually enough for that local repair. Reflexion is a broader, memory-backed pattern for learning from task feedback across trials, not a name for every corrected retry.
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 can improve availability, but it changes the correctness question: can the backup produce the same contract? 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. Without that adapter, 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. A retry is appropriate only when the failure is transient and nothing important has committed. A 429, 503, or dropped connection usually fits that pattern. A poisoned context or partial side effect doesn't.
Suppose the agent believes "rollback queued" even though the tool call timed out. Replaying the next step can duplicate production changes or make later reasoning depend on false state. The transport error tells you how the call ended, not what happened in the outside world.
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.
Use three recovery paths:
- 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.
The small classifier below turns that distinction into an executable choice. 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 replay
Production 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. Reconcile against the deployment controller with the rollback idempotency key, update durable state with the confirmed outcome, then resume from a checkpoint consistent with that outcome.
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
Ask whether the action that will execute still matches the task the system accepted. A dangerous failure happens when the agent's proposed operation diverges from that contract. The two common states are a wrong tool choice despite a valid request and wrong argument serialization despite choosing the right tool.
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.
Generated explanations don't constrain generated tool payloads. A model may describe a reasonable plan yet emit an unsupported parameter or a different identifier. Validate the executable payload against the task contract instead of asking for hidden reasoning or trusting a confident explanation.
Admit only actions that match the contract
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.
When a repair becomes Reflexion
Action validation fixes a malformed call. What if the call is valid but the strategy still produces no new evidence? The Reflexion paper describes an agent that turns task feedback into a verbal reflection, stores that reflection in episodic memory, and uses it to guide later trials.[8] Its defining feature isn't the word "critique." It's the feedback-to-memory loop across attempts: Attempt -> task feedback -> verbal reflection -> episodic memory -> next attempt.
The wrong-field example doesn't need this machinery because its validator already provides the fix. Reflexion becomes useful when a failure is safe to revisit but the diagnosis is strategic rather than syntactic. Suppose the deploy agent repeatedly reads the same summary endpoint and still can't explain a failure:
1[Attempt] get_run({"id": "RUN-842"}) -> "failed in unit-tests"
2[Feedback] No new evidence: failure cause still unsupported.
3[Reflection] "The summary endpoint cannot reveal the assertion. Query the failed job logs next."
4[Memory] Store that lesson for the next trial on this task.
5[Next attempt] get_logs({"run_id": "RUN-842", "job": "unit-tests"})The reflection changes strategy instead of merely repairing syntax. Scope memory carefully: a lesson learned for one repository, tool version, or incident can become stale or harmful elsewhere. Re-run tool allowlists, authorization, schema validation, and source-of-truth checks on the new proposal.
Reflection isn't proof: A verbal reflection remains generated guidance. It doesn't prove a factual claim or authorize a side effect.
What makes Reflexion different from a repair retry?
Answer
A repair retry uses immediate structured error feedback to correct the current proposal. Reflexion converts task feedback into a verbal lesson, stores it in episodic memory, and applies it to a later trial. It's useful for strategic failures that remain safe to revisit.
Most deterministic failures need only a structured error. Use memory-backed reflection for repeated or ambiguous failures that are safe to revisit and whose lessons have a clear scope. High-stakes or potentially committed effects should stop for approval or reconciliation instead.
When is a Reflexion loop overkill?
Answer
When the failure has a clear deterministic fix, such as "missing required field id." A concise structured error can steer the next proposal without adding generated reflection or memory. High-stakes writes need authorization or reconciliation, not self-critique.
The circuit breaker pattern
A circuit breaker stops a failing dependency from taking the rest of the agent with it. For RUN-842, that usually means failing fast once get_run or the model endpoint crosses a known error threshold, instead of letting every remaining step rediscover the outage.
The breaker has three states in its state machine: Closed lets requests flow and counts failures, Open fails fast while the dependency is unhealthy, and Half-open admits one probe after a cooldown.

Only dependency-health failures move the circuit toward Open. A schema or authorization error follows a correction path instead.
The labels above map to the runtime states: Closed is normal operation, Open avoids spending tokens on a dependency already known to be unhealthy, and Half-open tests recovery with one request. If that probe succeeds, traffic returns to Closed; if it fails, the circuit returns to Open.
Half-open keeps recovery controlled. Jumping from Open straight to Closed after a timeout would dump every queued agent call onto a still-failing API (the thundering-herd problem). One probe is cheaper than discovering the outage again with a burst of billable tokens.
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.
The implementation turns those two rules into state transitions. It gates Half-Open so only one probe runs at a time, then stamps each admitted call with a generation. Opening the circuit, or starting a new Half-Open generation, invalidates older in-flight completions. A stale success can't close a circuit that newer failures already 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.monotonic()
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.monotonic()
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())
108print("stale success left circuit open")1stale success left circuit openCount 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.
Choose breaker scope deliberately. A per-process breaker protects only the worker that observed failures and may be enough for a local resource. A fleet-wide breaker needs coordination through shared state, a service mesh, or provider-side routing. If you build it with a store such as Redis, make state transitions atomic and decide how the system behaves when that store is unavailable.
When does a circuit breaker need coordinated state in a multi-server deployment?
Answer
When the intended protection boundary is the whole fleet. Otherwise one worker may open its circuit while peers keep sending traffic to the same failing dependency. Coordination can come from shared state, a mesh, or upstream routing; a deliberately local breaker need not share state.
Fallback chains: graceful degradation
What can you still say when the live path is down? Start with the most capable route, validate its evidence, and move to a narrower claim only when that route fails. The chain below makes that order explicit:

Each fallback narrows its claim contract. Cached context must expose freshness; the final stage admits that live status is unavailable.
Each step trades open-ended behavior for a narrower claim. If the full RUN-842 agent fails or the circuit opens, move to a read-only lookup, then a timestamped cache, then an honest "live status unavailable" message. Fallback isn't automatically safe. A cache from 40 minutes ago can't answer "did the rollback finish?"
Set requires_live_data=True and evidence becomes part of the result contract. In the example, live tools and the read-only path both fail. The cached answer is rejected because the question needs current data, so the chain returns a static handoff instead of a stale "looked healthy" sentence.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class StrategyResult:
5 text: str
6 has_live_evidence: bool
7
8class FallbackChain:
9 DEFAULT_RESPONSE = (
10 "Live run status is unavailable right now. Please retry or contact on-call."
11 )
12
13 def execute(self, task: str, requires_live_data: bool = False) -> str:
14 strategies = [
15 ("primary_agent", self._full_agent_execution),
16 ("backup_agent", self._simplified_agent),
17 ("cached_context", self._cached_context),
18 ("static_fallback", self._static_fallback),
19 ]
20 for name, strategy in strategies:
21 try:
22 result = strategy(task)
23 except TimeoutError:
24 continue
25 if name == "static_fallback":
26 return result.text
27 if self._validate_result(result, requires_live_data):
28 return result.text
29 return self.DEFAULT_RESPONSE
30
31 def _validate_result(
32 self,
33 result: StrategyResult,
34 requires_live_data: bool,
35 ) -> bool:
36 if not result.text.strip():
37 return False
38 if requires_live_data and not result.has_live_evidence:
39 return False
40 return True
41
42 def _full_agent_execution(self, task: str) -> StrategyResult:
43 raise TimeoutError("get_run timed out")
44
45 def _simplified_agent(self, task: str) -> StrategyResult:
46 raise TimeoutError("read-only get_run timed out")
47
48 def _cached_context(self, task: str) -> StrategyResult:
49 return StrategyResult(
50 "Cached: RUN-842 looked healthy 40m ago",
51 has_live_evidence=False,
52 )
53
54 def _static_fallback(self, task: str) -> StrategyResult:
55 return StrategyResult(self.DEFAULT_RESPONSE, has_live_evidence=False)
56
57print(
58 FallbackChain().execute(
59 "What happened to run RUN-842?",
60 requires_live_data=True,
61 )
62)1Live run status is unavailable right now. Please retry or contact on-call.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, policy compliance, required evidence, freshness, and clear degraded-mode disclosure. A cached or static path must not answer a question that requires unavailable live data.
Pinned reminders are not controls
Long traces create a recall problem before they create a policy problem. Long-context studies show a positional bias: models often do best when relevant information appears near the beginning or end of the context window, and noticeably worse when it's buried in the middle.[9] As tool output and scratchpad text accumulate, critical constraints can drift into that low-salience region.
A pinned reminder repeats a short set of important instructions near the current task. This can help recall when a trace is long, but it isn't an enforcement boundary. Authorization checks, tool allowlists, and output validation still need to live outside the model. The earlier guardrails lesson separates model guidance from deterministic controls in more detail.
For RUN-842, a pinned block can remind the model not to invent CI status and to confirm writes. Repeating those lines at the end of a long trace may improve recall, but it still isn't a security boundary. Authorization, allowlists, and output checks live in code.
1class PinnedConstraintsAgent:
2 CRITICAL_CONSTRAINTS = """
3CRITICAL CONSTRAINTS (must follow):
41. Never disclose PII (Personally Identifiable Information)
52. Always confirm actions that modify data
63. Never invent a CI or deploy status
74. Maximum 10 tool calls per user request
8"""
9
10 def __init__(self, base_system_prompt: str):
11 self.base_system_prompt = base_system_prompt
12
13 def build_prompt(self, conversation_history: list, current_task: str) -> str:
14 prompt_parts = [
15 self.base_system_prompt,
16 "",
17 "=== CONVERSATION HISTORY ===",
18 self._format_history(conversation_history),
19 "",
20 "=== CURRENT TASK ===",
21 current_task,
22 "",
23 self.CRITICAL_CONSTRAINTS,
24 ]
25 return "\n".join(prompt_parts)
26
27 def _format_history(self, history: list) -> str:
28 return "\n".join(f"{msg['role']}: {msg['content']}" for msg in history[-10:])
29
30agent = PinnedConstraintsAgent("You look up CI runs.")
31prompt = agent.build_prompt(
32 [{"role": "user", "content": "What happened to run RUN-842?"}],
33 "Summarize RUN-842 without guessing.",
34)
35after_task = prompt.split("=== CURRENT TASK ===", maxsplit=1)[1]
36print("pinned after task:", "CRITICAL CONSTRAINTS" in after_task)
37print("ends with cap:", after_task.strip().endswith("Maximum 10 tool calls per user request"))1pinned after task: True
2ends with cap: TrueProduction tip: Keep the block short, prioritize task-critical reminders, and evaluate whether repeating it improves behavior. Moving more policy text to the end doesn't create a stronger security boundary.
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
Some failures should end automation, not trigger another model call. When an agent encounters high-stakes ambiguity, repeated failures, or a potential safety issue, pause and route a bounded review packet to an authorized human reviewer.
The earlier human-in-the-loop architecture lesson introduced review checkpoints. Here, HITL is a recovery destination: pause a failed or risky run and send a bounded review packet to an authorized person. Review doesn't guarantee correctness. Approvals can become stale, and execution can drift from the reviewed proposal. Bind approval to the exact action and revalidate it at execution time.
When to escalate
Use measurable triggers rather than a vague feeling that the model is unsure:
- A verifier or policy gate score crosses a documented review threshold
- Repeated tool-call failures or validation rejections
- A proposed write such as rollback, deploy, or delete
- Step, budget, or circuit limits that stop automation
The fixture's three-retry limit and 0.7 risk threshold are examples, not universal defaults. Calibrate both against evaluated failures, reviewer load, and the cost of a missed escalation. Copy allowlisted fields into the review packet; don't forward raw model or tool payloads.
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
9@dataclass
10class EscalationRequest:
11 reason: EscalationReason
12 context: dict[str, object]
13 priority: int
14 suggested_action: str
15
16class HumanInTheLoop:
17 def __init__(self, risk_review_threshold: float = 0.7, max_retries: int = 3):
18 self.risk_review_threshold = risk_review_threshold
19 self.max_retries = max_retries
20
21 def should_escalate(
22 self,
23 agent_state: dict[str, object],
24 failure_count: int = 0,
25 policy_risk_score: float | None = None,
26 ) -> EscalationRequest | None:
27 if agent_state.get("action_type") in {"delete", "deploy", "rollback"}:
28 return EscalationRequest(
29 reason=EscalationReason.SAFETY_CHECK,
30 context=agent_state,
31 priority=1,
32 suggested_action="Approve the exact action hash before execution",
33 )
34 if failure_count >= self.max_retries:
35 return EscalationRequest(
36 reason=EscalationReason.REPEATED_FAILURES,
37 context=agent_state,
38 priority=2,
39 suggested_action="Review failed attempts and provide guidance",
40 )
41 if (
42 policy_risk_score is not None
43 and policy_risk_score >= self.risk_review_threshold
44 ):
45 return EscalationRequest(
46 reason=EscalationReason.POLICY_REVIEW,
47 context=agent_state,
48 priority=3,
49 suggested_action="Review policy-flagged output",
50 )
51 return None
52
53review = HumanInTheLoop()
54rollback = review.should_escalate(
55 {
56 "action_type": "rollback",
57 "run_id": "RUN-842",
58 "action_hash": "rb-842-1",
59 }
60)
61lookup = review.should_escalate(
62 {"action_type": "lookup", "run_id": "RUN-842"},
63 failure_count=1,
64)
65print(rollback.reason.value if rollback else "none", rollback.priority if rollback else 0)
66print("lookup escalate:", lookup is not None)1safety_check 1
2lookup escalate: FalseEscalation 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 rollback or deploy, 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
An agent can return 200 OK and still fail its task. Production systems therefore need specialized observability alongside standard APM (Application Performance Monitoring): latency and error rates matter, but they won't reveal a loop or an unsupported claim by themselves.
Track agent-specific metrics to catch technically successful responses that fail 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 | Example starting condition |
|---|---|---|---|
| Loop rate | Derived ratio | Share of traces where loop detection triggered | Sustained increase over task-specific baseline |
| Recovery rate | Derived ratio | Share of intervened trajectories ending in a safe answer, fallback, or escalation | Drops below recovery SLO |
| Fallback rate | Derived ratio | Share of requests routed to a lower-capability path | Burns degraded-mode SLO |
| Step count | Histogram | Tool calls per user turn | High percentile approaches configured step cap |
| Token usage | Histogram | Input plus output tokens per turn | High percentile crosses soft budget |
| Validation rejection rate | Derived ratio | Share of validated outputs rejected by factual or schema checks | Sustained increase over task-specific baseline |
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 derived from validation and incident counters rather than stored directly as gauges. Define recovery rate as:
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.
OpenTelemetry's v1.41 GenAI conventions defined model-client duration and token-usage metrics and marked that GenAI surface as Development.[11] Pin a convention version in your instrumentation. Don't treat those names as a stable contract, and keep agent-level signals such as loop and recovery rate in your own documented namespace.
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 ratio is ordinary arithmetic. Production stacks often export the underlying counters through Prometheus. The snippet records the table's 100-trace example:
1class AgentMetrics:
2 def __init__(self) -> None:
3 self.interventions = 0
4 self.safe_recoveries = 0
5
6 def record_intervention(self, recovered: bool) -> None:
7 self.interventions += 1
8 if recovered:
9 self.safe_recoveries += 1
10
11 def recovery_rate(self) -> float:
12 return self.safe_recoveries / self.interventions
13
14metrics = AgentMetrics()
15for recovered in [True] * 72 + [False] * 28:
16 metrics.record_intervention(recovered)
17print(f"recovery rate: {metrics.recovery_rate():.0%}")1recovery rate: 72%Example alert logic
Alert conditions must be calibrated by task and consequence. Useful starting conditions include:
- Loop rate rises over baseline: Inspect traces for ambiguous tools, broken progress state, repeated dependency results, or an overly strict detector before assigning cause.
- Fallback traffic burns its SLO: Check primary dependencies, validation changes, routing, and task mix. Separate an outage from an intentional product rollout.
- High-percentile steps or tokens approach hard caps: Tighten optional exploration or compact validated state before user-facing stops dominate.
- A circuit remains Open: Investigate dependency health, client and network paths, timeout budgets, and breaker sensitivity.
Send these agent-specific metrics to your observability and incident-response tooling alongside standard server metrics. Alert on a rising loop rate so operators can stop affected runs before they degrade service or drive runaway API costs.
Common misconceptions
Deterministic service habits become unsafe when the control flow is generated. Keep these four boundaries in view:
Blind retries: Retrying the same hallucinating LLM call often repeats the failure or produces a different wrong answer. Effective recovery changes the strategy by modifying the prompt, switching models, or reducing the task scope.
Timeouts: A timeout doesn't prove that a side effect failed. Replay can duplicate work or create inconsistent state, so use idempotency keys, checkpoints, and explicit reconciliation against the source of truth.
Step caps: A step limit is a crude safety net. It doesn't stop an agent from wasting 15 steps (and thousands of tokens) on a futile loop before the limit; semantic loop detection stops that waste earlier.
Generic exception handlers: Catching every exception silently creates "zombie agents" that continue with corrupted state. Keep failures explicit and route them through validation checks or circuit breakers instead of swallowing them.
Practice: diagnose the failure
Pause before reading the sketches. For each scenario, name the failed boundary, choose the first safe recovery path, and say what evidence would let the run continue.
-
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: Progress failure. The
LoopBreakerwould catch the third identical call withargs_hashdeduplication. Also inspect why the completion condition didn't stop the run after a sufficient answer. -
Scenario 2: Proposal failure. Compare the emitted tool with the structured task contract and allowlist. Block
create_releasewhen the accepted intent is deploy status. A critic can add review signal but can't replace deterministic capability checks. -
Scenario 3: External-state failure. The timeout didn't mean "nothing happened." Use an idempotency key and read-after-write reconciliation before deciding whether any retry is necessary.
-
Scenario 4: Progress-budget failure.
TokenBudgetshould stop the run at its hard limit. A configured soft threshold should have stopped optional exploration and compacted validated state earlier. -
Scenario 5: Proposal failure. A tool allowlist rejects
fetch_run_secretbefore execution and returns structured feedback:Tool 'fetch_run_secret' not available. Available tools: get_run, get_logs, suggest_alternative.
Which scenario creates the greatest operational risk, and why?
Answer
Scenario 3 can duplicate a real rollback. Loops and hallucinated tools waste resources, but replaying a timed-out non-idempotent action can mutate production state and corrupt the run history.
Agent failure controls
The same few boundaries recur across every example. Use this list as a design review for a runtime, then check each control against a trace rather than treating it as a slogan.
- In the independent, equal-probability toy model,
nrequired steps succeed with probabilityp^n. Real trajectories add correlated faults and recovery boundaries, so measure task-level reliability directly. - Generated failures can look successful. Build validation checks for claims and actions, not exception handlers alone.
- Classify the failed boundary: proposal, dependency, external state, progress, or result. Recovery follows evidence, not a generic "agent error."
- 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 stores task feedback as verbal memory for later trials; it doesn't authorize actions or prove claims.
- 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 reminders can improve recall in long contexts, but enforcement remains outside the model.
- 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 recursive long-context work
These controls bound a conventional agent loop: every proposal, tool result, checkpoint, and recovery action has a contract. The next lesson, Recursive Language Models, keeps long inputs outside the active window and delegates targeted sub-calls. Recursion changes how context is decomposed. It doesn't remove budgets, validation, or recovery. Each sub-call still needs a trusted input boundary and a bounded way to fail.