Agent Reliability
Interview answer (say this first). An agent is a distributed system with a stochastic component, so you cannot make it deterministic. You make its failures bounded and visible. Classify failures and retry only the transient ones, with backoff, jitter, and an idempotency key. Cap every loop with a turn limit, a deadline, and a budget. Record a structured trace of each run: steps, tool calls, tokens, and cost. Test against golden traces and run scenario suites many times to measure a pass rate with a confidence interval, then gate changes in CI on that rate. Keep a human in the loop for irreversible actions and keep a kill switch ready. Reliability is the set of limits and records that make the agent safe to run unattended.
Note:
Verified. Every runnable example on this page was executed offline in plain Python (Python 3.14). All randomness is seeded, so the numbers below are reproducible. No model calls were made.
Why this exists
A demo works once. Production runs the same task ten thousand times, unattended, with money and side effects attached. Two facts turn small flaws into big ones:
- Failure compounds. If a step succeeds 97% of the time, a run of eight such steps succeeds about
0.97 ** 8, which is roughly 78%. The per-step number looked fine; the run number is not. - Non-determinism hides the cause. The same input can take a different path, call a different tool, or fail at a different step. Without a record of what happened, every bug report is a new investigation.
Here is what that looks like in practice:
- A retry fired after a timeout, and the refund was issued twice.
- A confused agent looped for forty turns and burned the monthly budget before anyone noticed.
- A model upgrade changed the tool-call rate. Success rate dropped four points and nobody had a baseline to compare against.
- An answer was generated with a fabricated citation. There was no trace of which document was retrieved, so it could not be reproduced.
- An irreversible
deleteran with no approval because the prompt said the agent should be careful.
Reliability engineering is the answer to all five. It does not remove the stochastic behaviour. It bounds the damage, records the evidence, and detects regressions before users do.
Tip:
The one-sentence purpose. Reliability turns an unpredictable agent into a system with defined limits, observable runs, and a measured, gated success rate.
Start from zero
| Word | Plain meaning |
|---|---|
| Reliability | The system does the right thing, or fails safely, across many runs. |
| Failure mode | A specific way the system can fail. Give each one a name and an owner. |
| Transient failure | A temporary error that may succeed if retried: a timeout, a 503, a rate limit. |
| Permanent failure | An error that will not fix itself: bad input, a validation error, a 400. |
| Degraded failure | A non-critical part is missing; the task can continue with less. |
| Fatal failure | The run must stop now; continuing is unsafe or pointless. |
| Retry | Running an operation again after a failure. |
| Backoff | Waiting longer between each retry so you do not hammer a struggling service. |
| Jitter | Randomising the wait so many clients do not retry at the same instant. |
| Idempotency | Doing the same operation twice has the same effect as doing it once. |
| Idempotency key | A unique id for an operation, stored durably, used to detect and skip duplicates. |
| Timeout | The maximum time to wait for one operation. |
| Deadline | The maximum time for the whole task, after which you stop. |
| Circuit breaker | After repeated failures, stop calling a service for a while and fail fast. |
| Fallback | A simpler alternative used when the primary path fails. |
| Budget | A hard limit on spend, tokens, or steps per run or per tenant. |
| Variance | How much a metric bounces between runs of the same input. |
| Pass rate | The fraction of runs that succeed. A reliability metric, not an accuracy metric. |
| Golden trace | A saved, canonical record of a known-good run, used to detect change. |
| Snapshot | A saved baseline value you compare future runs against. |
| Regression gate | A CI check that fails a change when a metric drops below its threshold. |
| SLI | A service-level indicator: the number you actually measure, like pass rate or p95 latency. |
| SLO | A service-level objective: the target for that indicator, like “99% pass rate”. |
| Error budget | How much failure the SLO allows; when it is spent, you stop shipping features and fix reliability. |
| Observability | Being able to understand a run from its records without guessing. |
| Trace / span | A trace is the whole run; a span is one timed step inside it. |
| p50 / p95 / p99 | Latency at the 50th, 95th, and 99th percentile. Averages hide the tail. |
| Human-in-the-loop | A person approves or reviews before an important action. |
| Canary | Sending a small share of traffic to a new version while watching its metrics. |
| Drift | The world changes, so yesterday’s good behaviour stops being good. |
Three distinctions matter most:
- Reliability vs accuracy. Accuracy asks “is the answer right?” Reliability asks “does the system behave within its limits and fail safely?” You need both, and they are measured differently.
- Retry vs fallback. Retry repeats the same attempt. Fallback changes the approach. Retry a timeout; fall back when the service is down.
- Test vs eval. Tests check the mechanics (tools, routing, limits). Evals check quality. A passing test suite does not mean the agent is good; it means the plumbing holds.
The core idea
Think about how airlines operate. They do not assume nothing goes wrong. They plan for it:
- A flight plan before takeoff — the plan-and-execute shape.
- Checklists at each phase — deterministic steps around the uncertain ones.
- A black box that records the flight — the trace.
- Redundancy and limits — fuel reserves, timeouts, a go/no-go decision.
- An incident process — investigate, reproduce, fix, and update the checklist.
An agent needs the same layers. Reliability is not one feature; it is a stack of defences, each catching what the one before it missed:
flowchart TD
A["Task arrives"] --> B{"Budget and deadline<br/>available?"}
B -->|no| Z["Reject cleanly<br/>do not start"]
B -->|yes| C["Run step with<br/>timeout + retry"]
C --> D{"Transient error?"}
D -->|yes, under limit| C
D -->|permanent| E["Classify and respond<br/>retry / fallback / stop"]
C --> F["Record span:<br/>tokens, cost, result"]
F --> G{"Loop cap reached<br/>or budget spent?"}
G -->|yes| H["Stop safely<br/>return partial state"]
G -->|no| I{"Irreversible action?"}
I -->|yes| J["Human approval"]
I -->|no| C
J --> C
H --> K["Trace stored for<br/>replay and eval"]
E --> K
The corresponding response table is the part to memorise:
| Failure class | Example | Correct response |
|---|---|---|
| Transient | Timeout, 503, rate limit | Retry with backoff, jitter, and a cap |
| Permanent | Invalid input, schema error, 400 | Do not retry; fix input or fail the step |
| Degraded | One source missing, model downgraded | Continue with a fallback and mark the result |
| Fatal | Guardrail trip, budget exhausted, unsafe action | Stop the run and escalate |
Misclassification is expensive in both directions: retrying a permanent error wastes time and can duplicate side effects, while not retrying a transient error throws away a run that would have succeeded.
How it works
- Write the SLOs before the code. Pick a small set: task success rate, p95 latency, cost per successful task, and unsafe-action rate. Give each a target and a measurement window. Without a target, “reliable” is an opinion.
- Enumerate the failure modes. List how the agent can fail: bad tool arguments, tool timeout, model refusal, guardrail trip, loop, context overflow, stale retrieval, duplicate side effect, and budget exhaustion. Give each a class and an owner.
- Bound every loop. Set
max_turns, a wall-clock deadline, and a token or cost budget. A limit is the difference between a bad run and an incident. - Classify before you respond. Map exceptions and status codes to transient, permanent, degraded, or fatal. The response is a policy decision, not an accident of which
exceptclause you happened to write. - Retry only transient failures, with backoff and jitter. Cap the attempts, and never retry a non-idempotent operation without an idempotency key.
- Make side effects idempotent. Before a write, check a durable key store. If the key is present, return the stored result instead of repeating the effect. This is what makes retries safe.
- Add fallbacks for degraded paths. A smaller model, a cached answer, a narrower tool, or a “best effort with a warning” result. Mark degraded output so downstream code and users know.
- Record a structured trace per run. For every step: name, start and end time, tool and arguments, result or error, tokens, and cost. Attach one run id across all agents and tool calls.
- Measure variance, not one run. Run each scenario many times and report the pass rate with a confidence interval, plus p50 and p95 latency and mean cost. A single run proves nothing.
- Keep golden traces for the critical paths. Canonicalise them by removing volatile fields such as ids and timestamps, then diff. A changed trace is a signal to review, not automatically a bug.
- Gate changes in CI. Compare the candidate’s pass rate to the stored baseline with a statistical test. Fail the build on a real drop, and let noise pass. Treat prompts and model versions like code.
- Put a human in front of irreversible actions, and keep a kill switch. Default to deny for deletes, payments, and outbound messages. Keep an operator action that stops runs and disables a tool.
- Run incident response like an SRE team. Detect, triage by class, reproduce from the trace, mitigate (roll back the prompt, model, or tool), then add the failure as a permanent eval case.
The syntax you will use
A structured run record. One record per run, one entry per step. This is the unit of observability.
from dataclasses import dataclass, field
@dataclass
class RunRecord:
run_id: str
steps: list = field(default_factory=list)
def add(self, name, ok, in_tok, out_tok, latency):
self.steps.append({"name": name, "ok": ok, "in_tok": in_tok,
"out_tok": out_tok, "latency": latency})
def total_tokens(self):
return sum(s["in_tok"] + s["out_tok"] for s in self.steps)
def failures(self):
return [s["name"] for s in self.steps if not s["ok"]]
Failure classification. A policy function, tested on its own.
from enum import Enum
class TransientError(Exception): pass # a timeout, a 503, a rate limit
class PermanentError(Exception): pass # bad input, a schema mismatch
class Kind(Enum):
TRANSIENT = "transient"; PERMANENT = "permanent"
DEGRADED = "degraded"; FATAL = "fatal"
def classify(exc) -> Kind:
if isinstance(exc, TransientError): return Kind.TRANSIENT
if isinstance(exc, (PermanentError, ValueError)): return Kind.PERMANENT
if isinstance(exc, FileNotFoundError): return Kind.DEGRADED
return Kind.FATAL
Backoff with jitter. Exponential growth, capped, with randomness so retries do not synchronise.
import random
def backoff_delay(attempt, base=0.5, cap=8.0, rng=None):
raw = min(cap, base * 2 ** attempt)
rng = rng or random
return raw / 2 + rng.uniform(0, raw / 2) # never exceeds the cap
Retry with an idempotency key. The key store is durable in production; a dict stands in here.
def run_with_retry(fn, idempotency_key, store, max_attempts=5, rng=None):
for attempt in range(max_attempts):
try:
return {"ok": True, "result": fn(attempt, store, idempotency_key)}
except TransientError:
if attempt == max_attempts - 1:
return {"ok": False, "error": "retries_exhausted"}
except PermanentError as e:
return {"ok": False, "error": f"permanent:{e}"}
return {"ok": False, "error": "unreachable"}
A timeout check. Prefer a real deadline over a per-call timeout when the task has a total budget.
def run_step(elapsed, deadline):
return "timeout" if elapsed > deadline else "ok"
A budget guard. Raise before the spend, not after.
class BudgetExceeded(Exception): pass
def check_budget(spent, new_cost, max_cost):
if spent + new_cost > max_cost:
raise BudgetExceeded(f"{spent + new_cost:.3f} > {max_cost}")
return spent + new_cost
A canonical golden trace. Strip the volatile fields before comparing.
VOLATILE = {"run_id", "timestamp", "latency_ms", "request_id"}
def canonical(trace):
return [{k: v for k, v in step.items() if k not in VOLATILE} for step in trace]
A regression gate. Compare two pass rates with a two-proportion z-test and require significance.
import math
def two_proportion_z(x1, n1, x2, n2):
p1, p2 = x1 / n1, x2 / n2
p = (x1 + x2) / (n1 + n2)
se = math.sqrt(p * (1 - p) * (1 / n1 + 1 / n2))
return 0.0 if se == 0 else (p1 - p2) / se
# fail the build when z > 1.96 and the candidate is worse
Budget and deadline settings in the OpenAI Agents SDK. Verified against openai-agents 0.22.2.
from agents import Runner, RunConfig
# max_turns defaults to 10 and raises MaxTurnsExceeded when exceeded
result = Runner.run_sync(agent, "task", max_turns=8,
run_config=RunConfig(workflow_name="support-run"))
Examples: simple to real
Example 1 — classify first, then decide.
A single classify function maps errors to a policy. Verified:
TransientError -> transient
PermanentError -> permanent
ValueError -> permanent
FileNotFoundError -> degraded
KeyboardInterrupt -> fatal
The value of this table is that the retry policy reads from it. No one has to guess whether a given exception is retryable.
Example 2 — retry and idempotency.
A flaky tool fails twice with a 503 and then succeeds. With a durable key store, verified:
retry flaky: ok, attempts: 3, effects: ['idem-1']
dedup re-run: no new effect, replayed result 'side-effect-done'
permanent: 1 attempt, permanent:schema mismatch
The third attempt succeeded, and a later replay did not repeat the side effect. A permanent error stopped immediately after one attempt. Both halves matter: retry transient, never retry permanent.
Example 3 — measure variance over many runs.
Two hundred seeded runs of a stochastic agent, each with cost and latency. Verified summary:
n=200 success_rate=0.84
p50_latency=1.18s p95_latency=1.93s std_latency=0.44
mean_cost=$0.0241
success-rate 95% Wilson CI = [0.783, 0.884]
The pass rate is 0.84, but the honest statement is 0.84 with an interval. A single run would have told you nothing about any of this.
Example 4 — canonical golden traces.
Two runs with different ids and timestamps canonicalise to the same trace; a changed tool and a failed step do not. Verified:
same behaviour: trace match: True
changed tool: change detected: True
This is how you catch a behaviour change without failing on a harmless new run_id.
Example 5 — a regression gate.
Two hundred samples per version. Verified:
regression: baseline 0.885 candidate 0.740 z=3.71 -> fail the build
noise: baseline 0.870 candidate 0.875 z=-0.15 -> pass
The gate fires on the real drop and stays quiet on a difference inside noise. A gate that fires on noise gets ignored, which is worse than no gate.
Example 6 — a budget guard and a deadline stop a runaway.
The budget is $0.05; three steps of $0.02 each. Verified:
budget guard raised at spend: $0.040 -> 0.060 > 0.05
deadline: elapsed 0.4s / 1.0s -> ok; elapsed 1.2s / 1.0s -> timeout
The run stopped at the third step, before the overspend, and the deadline check flagged the slow step. Limits work only if they are checked before the action.
Example 7 — one record explains the run.
A record with three steps. Verified totals:
total_tokens=670 total_latency=1.0s failures=['write']
That is the minimum needed for a postmortem: what ran, what it cost, and where it failed. Correlate this record with the SDK trace to get the full picture.
In production
- Retry only idempotent operations. A retry after a timeout can duplicate a payment or an email. Store an idempotency key with the result before responding, and check it before acting.
- Cap retries and add jitter. Unbounded retries amplify an outage. Jitter prevents every client from retrying at the same moment and turning a blip into a thundering herd.
- Treat
MaxTurnsExceededas an incident signal. It means the agent did not converge. Alert on its rate; do not swallow it as a normal ending. - Separate model errors from tool errors from validation errors. Each has a different fix. A validation error is usually a schema or prompt problem, a tool error is usually infrastructure, and a model refusal needs an escalation path.
- Log the full trace, with redaction. Tool arguments and outputs often contain personal data. Redact before export, sample the volume, and keep the raw trace only where policy allows.
- Version prompts and models, and canary changes. A prompt edit is a deploy. Roll it out to a small percentage and watch the pass rate, cost, and latency before a full rollout.
- Treat the pass rate as the release gate. Report it with a confidence interval, never as one successful run. A change that moves the rate by less than the interval has not been shown to help.
- Canonicalise golden traces or they will break constantly. Ids, timestamps, and latencies change every run. Compare structure and outcomes, not incidental values.
- Set budgets per run and per tenant. One user should not be able to consume the shared budget. Alert at a percentage of budget, not only at exhaustion.
- Default to deny for irreversible actions. Deletes, payments, and outbound messages need explicit approval. The agent should propose, and a human or a policy should dispose.
- Add circuit breakers and fallbacks for flaky dependencies. After repeated failures, fail fast and use the fallback instead of queuing more doomed attempts.
- Rehearse incident response. Know how to disable a tool, roll back a prompt, and stop in-flight runs. After every incident, add the case to the eval suite so it cannot return silently.
Interview questions
1. How do you test a non-deterministic agent?
Answer. Two layers. First, deterministic unit tests with a scripted model: feed fixed model replies and assert on the run’s items, tool calls, routing, and limits. That tests the plumbing with zero variance. Second, repeated live or recorded runs of a scenario suite, reporting a pass rate with a confidence interval, plus latency and cost. A single successful run is not evidence.
Follow-up: “How many repetitions?” Enough that the confidence interval is narrow enough to detect the change you care about. Compute it; do not guess. Start with the failures that matter and grow the set from production incidents.
Trap. Claiming an agent is reliable because it passed once, or because a scripted test passed. Scripted tests prove mechanics, not model behaviour.
2. How do you retry an agent safely?
Answer. Classify the error first. Retry only transient failures, with exponential backoff, jitter, and a maximum attempt count. Make every side-effecting tool idempotent using a durable key, so a retry or a duplicate delivery cannot repeat the effect. Never retry a permanent error, and never retry a non-idempotent write without a key.
Follow-up: “What about retrying the model call itself?” It is usually safe because generation is a read, but it costs tokens and may return a different answer. Retrying a tool is where duplicate side effects happen, so the key belongs at the tool layer.
Trap. A blanket except Exception: retry. That retries permanent errors, triples cost, and can duplicate writes.
3. What is a golden trace, and how do you keep it useful?
Answer. A golden trace is a saved record of a known-good run for a critical path. You compare new runs against it to detect behaviour change. To keep it useful, canonicalise it: strip ids, timestamps, and latencies so harmless churn does not fail the comparison, and compare the meaningful structure — steps, tools, and outcomes. Treat a diff as a prompt to review, not an automatic failure.
Follow-up: “What does a golden trace not catch?” Quality. It verifies that the right steps happened, not that the answer is good. Pair it with an eval suite and human review.
Trap. Comparing raw JSON including ids and timings. The test fails on every run and the team disables it.
4. How do you set an SLO for an agent?
Answer. Choose a small set of user-facing indicators: task success rate, p95 latency, cost per successful task, and unsafe-action rate. Set a target and a measurement window for each, and define how you measure success (automated check, human label, or both). Track an error budget and use it to decide when to slow feature work and fix reliability.
Follow-up: “What makes an agent SLO different from a service SLO?” The numerator is fuzzy. “Success” may require an evaluator or a human, and it varies by task, so define it per task type and keep the automated part conservative.
Trap. Setting an SLO on average latency and ignoring the tail. Agent latency is long-tailed; p95 or p99 is what users feel.
5. How do you control cost?
Answer. Budget per run and per tenant, cap turns and tokens, and measure cost per successful task, not per call. Route simple work to smaller models, isolate context so workers do not carry the whole conversation, cache retrieval and repeated tool calls, and stop a run the moment it exceeds its budget. Alert at a percentage of budget so you act before the limit.
Follow-up: “Why cost per successful task?” A cheap change that fails more often can cost more in total, because every failure consumes tokens and then gets retried or re-run. The unit that matters is the finished task.
Trap. Tracking total spend only. It tells you what you spent, not which task, tenant, or change caused it.
6. What does observability for an agent need to capture?
Answer. One trace per run, correlated across agents and tools, with a span per step. Each span records the tool name and arguments, the result or error, the model and prompt version, tokens, cost, and duration. Add the run’s outcome and any human decision. That is enough to attribute a failure to a step, estimate its cost, and replay or reproduce it.
Follow-up: “What is the biggest practical problem?” Volume and privacy. Tool payloads are large and often sensitive, so you redact, sample, and set retention deliberately.
Trap. Logging only the final answer. The failure is almost never in the final answer alone; it is in the step that produced it.
7. How do you handle a model or prompt upgrade?
Answer. Treat it as a deploy. Pin the model version, keep prompts in version control, and run the full eval suite and scenario runs before rollout. Canary the change to a small share of traffic and compare pass rate, latency, and cost with a statistical test. Keep the previous version ready to roll back, and record which version produced each production trace.
Follow-up: “What is the failure mode of a silent upgrade?” A provider-side model change can alter tool-use and formatting behaviour with no code change. Monitoring the pass rate catches it; pinning versions makes it visible.
Trap. Comparing one run before and one run after. Variance alone produces apparent regressions.
8. How do you handle irreversible actions?
Answer. Assume the agent will eventually ask to take one. Require explicit approval for deletes, payments, and outbound messages, and default to deny. Use a structured approval request with a clear description and the exact arguments, record who approved what, and keep a kill switch. Where possible, make the action reversible with a draft or a soft delete instead.
Follow-up: “Where should approval live?” In deterministic code around the tool, not in the prompt. A prompt-level instruction is a suggestion; a guardrail or approval gate is enforcement.
Trap. Relying on the system prompt to prevent the action. The model can still call the tool; the enforcement must be outside the model.
Remember this
- You cannot remove the randomness; you bound it. Limits, records, and gates are the reliability stack.
- Classify failures, then retry only transient ones with backoff, jitter, a cap, and an idempotency key.
- Measure pass rate with a confidence interval over many runs, plus p95 latency and cost per successful task.
- Golden traces and CI gates catch behaviour changes before users do; canonicalise the traces so they stay credible.
- Irreversible actions need approval outside the model, and every incident becomes a permanent test case.