Reliable, observable agents
An agent is a non-deterministic loop — and left unattended it can spin forever, blow a budget, call a tool it should never touch, or quietly drift off-task. This expert chapter is about making an agent trustworthy enough to run without a human watching it: hard budgets and loop caps that stop it cleanly and say why, guardrails that gate the input, the tool calls, and the final output, honest determinism practice (LLMs are not fully reproducible — you engineer around that), observability so every step leaves a trace you can replay, and trajectory evaluation that scores the whole path, not just the last sentence. Every lab runs offline against a deterministic fake model, so you can see the reliability machinery with nothing hidden.
claude-opus-4-8) drops into the same slot and the machinery around it is unchanged. All token and latency numbers are simulated fixed values (no wall-clock, no randomness) so the labs print the same thing every time — they are illustrative, not benchmarks.Learning objectives
- Say what reliable means for an agent — bounded, guarded, observable, and evaluable, not just “usually right”.
- Enforce budgets and loop caps (max steps, tokens, tool calls) so a run stops cleanly and reports why.
- Build a guardrail layer that gates the input, the tool calls, and the output.
- Reason honestly about determinism — why LLMs aren't fully reproducible and what you pin anyway.
- Emit a structured trace of every step and read it as the unit of agent debugging.
- Evaluate a trajectory — right tools, sane order, goal reached, within budget — not just the answer.
1 · The reliability problem
A one-shot prompt has one failure mode: the answer is wrong. An agent is a loop where a non-deterministic model decides the next action over and over, so it inherits a whole family of failure modes that only appear over time. It can loop forever, ping-pong between two tools, blow a token budget, call a destructive tool, or drift off the original task without ever crashing. None of these show up in a quick demo — they show up at 3 a.m. when nobody is watching.
So “reliable” for an agent is not “the model is smart.” It is a set of properties you build around the model:
| Property | What it means | How this chapter delivers it |
|---|---|---|
| Bounded | The run always terminates — it cannot spin or bill forever. | Hard budgets and loop caps (§2) that stop and report the reason. |
| Guarded | It can't be pushed into a disallowed request or a dangerous action. | Input / tool / output guardrails (§3) that intercept before harm. |
| Reproducible-enough | You can re-run and explain what happened, despite model non-determinism. | Pin what you can, log everything to replay (§4). |
| Observable | Every step leaves a structured record you can inspect. | A tracer that records thought / tool / args / result (§5). |
| Evaluable | You can score whether a whole run was good, not just its last line. | Trajectory evaluation against expected tools + goal + budget (§6). |
2 · Budgets & loop caps — bound every run
The first and most important reliability control is a set of hard caps on the loop. A real agent can loop for real reasons — a tool that always errors, a goal it can't satisfy, two tools it bounces between — and without a cap that becomes an infinite, billable run. You bound it on several axes at once: max steps, max tokens (a cost budget), max tool calls, and in production a wall-clock timeout. When any cap trips, the loop stops cleanly and tells you which one — a caught, reported limit, never a silent hang.
Here is an agent loop wrapped in hard caps. The fake model would answer on its fourth turn; we run it three ways — once where it finishes normally, once with a tight token budget that trips first, and once with a tight step cap. Each returns a clean result saying exactly why it stopped. We simulate token counts with fixed values so there is no wall-clock and the output is deterministic:
budget.py# An agent loop with HARD CAPS. The 'model' is a deterministic fake so this
# runs offline; a real LLM drops into the same slot. We simulate tokens and
# tool calls with fixed counts (no wall-clock, no randomness).
def fake_model(step):
# Turns 0..2 call a tool; turn 3 would answer -- but caps may stop us first.
if step < 3:
return {'type': 'tool_call', 'name': 'search', 'sim_tokens': 120}
return {'type': 'final', 'text': 'done: found the answer', 'sim_tokens': 90}
def run_agent(question, max_steps=8, max_tokens=1000, max_tool_calls=5):
used_tokens = 0
tool_calls = 0
for step in range(max_steps + 1):
if step >= max_steps:
return {'stop': 'STEP_CAP', 'step': step, 'tokens': used_tokens, 'tool_calls': tool_calls}
decision = fake_model(step)
used_tokens += decision['sim_tokens']
if used_tokens > max_tokens:
return {'stop': 'TOKEN_BUDGET', 'step': step, 'tokens': used_tokens, 'tool_calls': tool_calls}
if decision['type'] == 'final':
return {'stop': 'FINAL_ANSWER', 'step': step, 'tokens': used_tokens,
'tool_calls': tool_calls, 'text': decision['text']}
tool_calls += 1
if tool_calls > max_tool_calls:
return {'stop': 'TOOL_CALL_CAP', 'step': step, 'tokens': used_tokens, 'tool_calls': tool_calls}
return {'stop': 'STEP_CAP', 'step': max_steps, 'tokens': used_tokens, 'tool_calls': tool_calls}
# 1. Normal run: reaches a final answer within every budget.
print('normal :', run_agent('find the answer'))
# 2. A tight token budget trips first -- the loop stops cleanly and says why.
print('budget :', run_agent('find the answer', max_tokens=200))
# 3. A tight step cap trips before the model would ever answer.
print('stepcap:', run_agent('find the answer', max_steps=2, max_tokens=10000))
normal : {'stop': 'FINAL_ANSWER', 'step': 3, 'tokens': 450, 'tool_calls': 3, 'text': 'done: found the answer'}
budget : {'stop': 'TOKEN_BUDGET', 'step': 1, 'tokens': 240, 'tool_calls': 1}
stepcap: {'stop': 'STEP_CAP', 'step': 2, 'tokens': 240, 'tool_calls': 2}
The three runs are the whole point: the same agent stops for three different, named reasons, and every stop carries how far it got (step, tokens, tool calls). A monitored production agent emits exactly this — you alert on the ratio of FINAL_ANSWER to cap-hit exits, because a rising cap-hit rate means something is confusing your agent.
STEP_CAP or TOKEN_BUDGET rather than FINAL_ANSWER, the agent isn't reliably solving the task — the cap is masking a real problem (bad tool, ambiguous goal, weak prompt). Track why runs end, not just that they ended.3 · Guardrails — gate input, tools, and output
Loop caps stop a run from running away. Guardrails stop it from doing the wrong thing while it runs. They sit at three boundaries, because harm can enter or leave at any of them:
| Guardrail | Sits at | Catches |
|---|---|---|
| Input guardrail | Before the model runs. | Disallowed or unsafe requests — reject early so the model never even attempts them. |
| Tool guardrail | Between the model's proposal and execution. | Dangerous tool calls — refuse, or require human confirmation, before anything executes. |
| Output guardrail | After the model answers, before the user sees it. | Leaked secrets, unsafe content, or answers that fail a format/policy check. |
Here is a guardrail layer enforcing all three. The input gate refuses a request containing a banned term; the tool gate intercepts a dangerous tool the model proposed; the output gate redacts a secret that slipped into the answer. All three are plain rules here — a real system often adds a model-based classifier for fuzzier judgments, but the placement is identical:
guardrails.py# A guardrail layer with THREE gates around the agent:
# input -> block a disallowed request before the model ever runs,
# tool -> block a dangerous tool call the model proposes,
# output -> validate/filter the final answer before it reaches the user.
# All checks are plain rules; a real system may add a model-based classifier.
class Blocked(Exception):
pass
BANNED_INPUT = ('password', 'ssn', 'wire money')
DANGEROUS_TOOLS = {'delete_database', 'transfer_funds', 'run_shell'}
def input_guard(request):
low = request.lower()
for term in BANNED_INPUT:
if term in low:
raise Blocked(f'input refused: contains disallowed term {term!r}')
return request
def tool_guard(name, args):
if name in DANGEROUS_TOOLS:
raise Blocked(f'tool refused: {name!r} is dangerous (needs human approval)')
return name, args
def output_guard(answer):
# Redact anything that looks like a secret before it leaves the system.
import re
redacted = re.sub(r'\bsk-[A-Za-z0-9]+', '[REDACTED_KEY]', answer)
if len(redacted) > 200:
redacted = redacted[:200] + ' ...'
return redacted
def handle(request, proposed_tool, answer):
try:
input_guard(request)
tool_guard(*proposed_tool)
except Blocked as e:
return {'status': 'blocked', 'reason': str(e)}
return {'status': 'ok', 'answer': output_guard(answer)}
# 1. Clean request, safe tool, secret leaked in the answer -> runs, output redacted.
print(handle('what is our refund policy?', ('search_docs', {'q': 'refund'}),
'Our policy is 30 days. (debug key sk-abc123XYZ)'))
# 2. Disallowed input -> blocked before the model runs at all.
print(handle('email me the admin password', ('search_docs', {}), 'irrelevant'))
# 3. Model proposes a dangerous tool -> tool guard intercepts it.
print(handle('clean up old records', ('delete_database', {'name': 'prod'}), 'irrelevant'))
{'status': 'ok', 'answer': 'Our policy is 30 days. (debug key [REDACTED_KEY])'}
{'status': 'blocked', 'reason': "input refused: contains disallowed term 'password'"}
{'status': 'blocked', 'reason': "tool refused: 'delete_database' is dangerous (needs human approval)"}
Three requests, three outcomes: one runs but has its output sanitized, one is refused at the input boundary before wasting a model call, one is refused at the tool boundary before touching the database. The guardrails are ordinary code — deterministic, testable, cheap — sitting outside the probabilistic model. That is the design principle: put the safety where you can guarantee it.
delete_database no matter how the model phrases its request. Prompt instructions reduce the rate of bad attempts; guardrails guarantee the bad attempt fails.4 · Determinism & reproducibility — the honest version
Here is the uncomfortable truth stated plainly: LLMs are not fully deterministic. Even at temperature=0, identical inputs can produce different outputs run to run — floating-point non-associativity across GPUs, batching, and model updates all leak in. You cannot make an agent bit-for-bit reproducible the way you can a pure function. So the reliability goal is not “eliminate variance” — it's “minimize what you can, and log enough to explain and replay any run.”
| Lever | What it does | Honest limit |
|---|---|---|
| Low temperature | Makes the model pick the most likely token — less wandering. | Reduces variance; does not remove it. Same input can still differ. |
| Seed (where offered) | Some APIs accept a seed for best-effort repeatability. | Best-effort only, and not offered by every provider or model. |
| Pin the model + tool versions | Freeze the exact model id and your tool code. | A silent model update or a changed tool breaks reproducibility even at temp 0. |
| Log everything (the trace) | Record inputs, decisions, tool results, and outputs per run. | Doesn't prevent variance — but lets you replay and explain any run after the fact. |
claude-opus-4-8, not a floating alias) so at least the model isn't silently swapped under you. This is why §5 (tracing) is the backbone of the whole chapter.5 · Observability — the trace is the unit of debugging
You cannot debug an agent by staring at its final answer — a wrong answer tells you nothing about where the run went off. The unit of agent debugging is the trace: a structured, ordered record of every step — each thought, each tool call with its args, each result, plus the tokens and latency the step cost. With a trace you can see the agent “think out loud” after the fact and pinpoint the step that broke. Without one, every failure is a mystery.
Here is a tiny tracer. A real agent calls tracer.record() at each step of its loop; we replay a fixed, deterministic trajectory (a two-tool lookup) so the numbers are stable. It prints a readable trace and a run summary — steps, tool calls, and simulated token/latency totals:
tracer.py# A tiny TRACER: record every step of a run (thought, tool, args, result,
# simulated tokens + latency), then print a readable trace and a summary.
# Numbers are simulated fixed values -- no wall-clock, no randomness.
class Tracer:
def __init__(self):
self.spans = []
def record(self, kind, name, detail, sim_tokens, sim_ms):
self.spans.append({'kind': kind, 'name': name, 'detail': detail,
'tokens': sim_tokens, 'ms': sim_ms})
def summary(self):
steps = len(self.spans)
tool_calls = sum(1 for s in self.spans if s['kind'] == 'tool')
tokens = sum(s['tokens'] for s in self.spans)
ms = sum(s['ms'] for s in self.spans)
return {'steps': steps, 'tool_calls': tool_calls, 'sim_tokens': tokens, 'sim_ms': ms}
def show(self):
for i, s in enumerate(self.spans):
print(f" [{i}] {s['kind']:7} {s['name']:14} {s['detail']:32} "
f"tok={s['tokens']:>3} ms={s['ms']:>3}")
# Simulate one agent run over the tracer. A real agent calls tracer.record()
# at each step of its loop; here we replay a fixed, deterministic trajectory.
def run_traced():
t = Tracer()
t.record('thought', 'plan', 'need city then its population', 40, 20)
t.record('tool', 'geocode', "args={'city':'Paris'}", 25, 60)
t.record('thought', 'reflect', 'have coords, now population', 35, 20)
t.record('tool', 'lookup', "args={'id':'FR-75'}", 25, 55)
t.record('answer', 'final', 'Paris population is 2,100,000', 50, 25)
return t
tracer = run_traced()
print('TRACE:')
tracer.show()
print('SUMMARY:', tracer.summary())
TRACE:
[0] thought plan need city then its population tok= 40 ms= 20
[1] tool geocode args={'city':'Paris'} tok= 25 ms= 60
[2] thought reflect have coords, now population tok= 35 ms= 20
[3] tool lookup args={'id':'FR-75'} tok= 25 ms= 55
[4] answer final Paris population is 2,100,000 tok= 50 ms= 25
SUMMARY: {'steps': 5, 'tool_calls': 2, 'sim_tokens': 175, 'sim_ms': 180}
Read the trace top to bottom and you can see the reasoning: plan, look up coordinates, reflect, look up population, answer. If the agent had called geocode five times, or answered before the lookup returned, the trace would show it immediately. The summary is what you'd chart per run — steps, tool calls, and cost — and it's the same data the budget caps in §2 enforce and the evaluator in §6 scores. One record, three uses.
charge_card”), aggregate them (“p95 tool calls per run”), and feed them to the trajectory evaluator. Real systems ship these to a tracing backend (OpenTelemetry-style spans or a dedicated LLM-observability tool); the shape — an ordered list of typed, attributed steps — is what matters, and it's exactly what you built above.6 · Evaluating agent trajectories
Evaluating a chatbot is easy: compare its one answer to a reference. Evaluating an agent is harder, because two runs can reach the same answer by wildly different paths — one clean, one that thrashed through six wrong tools and got lucky. You must score the whole trajectory, not just the final line. The practical questions are concrete: did it use the right tools, in a sane order, did it actually reach the goal, and did it stay within budget?
Here is a trajectory evaluator. It takes a recorded trace (the same shape the tracer in §5 produces) and scores it on three deterministic checks — expected tool set, goal reached, within budget — returning a pass/fail with a reason for each. We score a good trajectory and a bad one so you can see both:
trajectory.py# Evaluate a whole TRAJECTORY, not just the final answer. We score three
# things: did it use the expected tools, did it reach the goal, did it stay
# in budget? Each check is a deterministic rule over a recorded trace.
def evaluate(trace, expected_tools, goal_marker, max_steps, max_tool_calls):
used_tools = [s['tool'] for s in trace if s['kind'] == 'tool']
tool_calls = len(used_tools)
steps = len(trace)
reached = any(goal_marker in s.get('text', '') for s in trace if s['kind'] == 'answer')
reasons = []
# 1. right tool set (order-independent: did it use exactly what was expected?)
tools_ok = set(used_tools) == set(expected_tools)
reasons.append(('expected_tools', tools_ok,
f'used {used_tools}, expected {sorted(expected_tools)}'))
# 2. reached the goal
reasons.append(('goal_reached', reached, f'goal_marker={goal_marker!r}'))
# 3. within budget
budget_ok = steps <= max_steps and tool_calls <= max_tool_calls
reasons.append(('within_budget', budget_ok,
f'{steps} steps / {tool_calls} tool calls '
f'(caps {max_steps}/{max_tool_calls})'))
passed = all(ok for _, ok, _ in reasons)
return passed, reasons
# A GOOD trajectory: uses both expected tools, reaches the goal, in budget.
good = [
{'kind': 'tool', 'tool': 'geocode'},
{'kind': 'tool', 'tool': 'lookup'},
{'kind': 'answer', 'text': 'Paris population is 2,100,000 [done]'},
]
# A BAD trajectory: wrong tool, never reached the goal, over the tool cap.
bad = [
{'kind': 'tool', 'tool': 'geocode'},
{'kind': 'tool', 'tool': 'guess'},
{'kind': 'tool', 'tool': 'guess'},
{'kind': 'answer', 'text': 'I think it is somewhere around a lot'},
]
for label, tr in [('GOOD', good), ('BAD', bad)]:
passed, reasons = evaluate(tr, expected_tools={'geocode', 'lookup'},
goal_marker='[done]', max_steps=5, max_tool_calls=2)
print(f'{label}: {"PASS" if passed else "FAIL"}')
for name, ok, detail in reasons:
print(f' {"ok " if ok else "X "} {name:15} {detail}')
GOOD: PASS
ok expected_tools used ['geocode', 'lookup'], expected ['geocode', 'lookup']
ok goal_reached goal_marker='[done]'
ok within_budget 3 steps / 2 tool calls (caps 5/2)
BAD: FAIL
X expected_tools used ['geocode', 'guess', 'guess'], expected ['geocode', 'lookup']
X goal_reached goal_marker='[done]'
X within_budget 4 steps / 3 tool calls (caps 5/2)
The bad run fails on all three axes — wrong tools, goal never reached, over the tool budget — and the reasons tell you which so you can fix the right thing. Run this over a suite of recorded trajectories and you have an automated agent-eval harness: a regression test for behavior, not just for answers. When you change a prompt or a tool, you re-run the suite and watch the pass rate.
claude-opus-4-8 with a grading prompt; treat its score as one noisy signal alongside the deterministic checks, never as ground truth on its own.7 · Failure taxonomy & mitigations
Reliability engineering is mostly knowing the failure modes and having a named mitigation for each. Here is the field guide for agents — every row is something you will eventually see, and the mitigation column is the toolkit from this chapter (and its neighbours):
| Failure | What it looks like | Mitigation |
|---|---|---|
| Infinite loop | Never returns; ping-pongs between tools or repeats one call. | Hard loop caps — max steps / tool calls / tokens (§2). Detect repeated identical calls. |
| Budget blowout | Finishes, but burned far more tokens/time than the task warranted. | Token/time budgets (§2); alert on budget-exit rate; simpler pattern (4.2). |
| Wrong tool | Reaches for a tool that can't answer the question. | Better tool descriptions (4.3); catch it in trajectory eval's expected-tools check (§6). |
| Hallucinated tool args | Calls a real tool with invented or malformed arguments. | Argument validation at the tool boundary (4.3); reject and feed the error back. |
| Dangerous action | Proposes a destructive or out-of-policy call. | Tool guardrail — default-deny, confirm for destructive (§3); allow-lists (4.3). |
| Gave up early | Stops and answers before the goal is actually met. | Goal-reached check in trajectory eval (§6); a reflection gate (4.2). |
| Ignored the result | Gets a tool result, then answers as if it never saw it. | Inspect the trace (§5) to catch it; assert the answer references the observation. |
| Off-task drift | Wanders onto a tangent unrelated to the original request. | Input guardrail + goal check; trace review; keep the goal in every step's context. |
Your agent finishes every run, but half of them stop on STEP_CAP instead of FINAL_ANSWER. Is the cap doing its job, and what does the pattern tell you?
Show answer
Why can't you rely on a temperature=0 setting to make an agent reproducible, and what do you do instead?
Show answer
temperature=0, LLMs are not bit-for-bit deterministic — floating-point non-associativity across hardware, batching, and model updates cause run-to-run variance. So you don't chase perfect reproducibility; you pin what you can (exact model id, tool versions, low temperature) and log the full trace so any run can be replayed and explained after the fact. Reliability rests on the trace, not on determinism.🪜 Practice — from bounded to trustworthy beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
In budget.py, add a max_sim_ms cap and a fixed sim_ms per step (simulate latency deterministically — no real clock). Stop with 'TIME_BUDGET' when it trips.
Show solution
Give eachfake_model decision a sim_ms (e.g. 50), accumulate used_ms like used_tokens, and return {'stop': 'TIME_BUDGET', ...} when used_ms > max_sim_ms. Because the per-step ms is fixed, the result is deterministic — a stand-in for a real wall-clock timeout, which you'd implement with a real deadline in production.
Extend budget.py so that if the model proposes the same tool with the same args twice in a row, the loop stops with 'REPEAT_LOOP' — a cheaper trip than waiting for the step cap.
Show solution
Track the previous(name, args); if the new proposal equals it, return {'stop': 'REPEAT_LOOP', ...}. Repeated-identical-call detection catches the most common spin (a tool that always errors, or a model stuck re-asking) far earlier than the raw step cap.
In guardrails.py, add an output check that refuses to return an answer containing a banned phrase (e.g. a competitor's name), turning the result into {'status': 'blocked', ...} instead of the text.
Show solution
Inside (or after)output_guard, scan the redacted answer for banned phrases and, on a hit, return a blocked result. Note this is an output gate — it protects what leaves the system, complementing the input gate that protects what enters it.
Change the tool guard so a blocked dangerous call, instead of ending the run, returns an error observation the agent can read and recover from (e.g. “that tool needs approval — try a safe alternative”). Explain the trade-off versus hard-stopping.
Show solution
Havetool_guard return a structured error result rather than raising, and feed it back into the loop as the tool's observation (the pattern from 4.2/4.3). The trade-off: recovering is more helpful but risks the model looping trying variants — so keep the loop cap. Hard-stop when the attempt itself is a security event you must not retry.
The evaluator in trajectory.py checks the tool set but not the order. Add a check that the tools appeared in an expected sequence (e.g. geocode before lookup), and show a trajectory that has the right set but the wrong order and correctly FAILs.
Show solution
Compare the orderedused_tools list against an expected_sequence (exact match, or a subsequence check). A run that calls lookup before geocode has the right set but fails the order check — which matters when a later tool depends on an earlier one's output (dependent calls, 4.3). Order is part of a sane trajectory, not just tool identity.
Combine §2, §5, and §6: run the budgeted loop while recording each step to the tracer, then feed the resulting trace into the trajectory evaluator — so one run produces a bounded execution, a readable trace, and a pass/fail score. Describe what you'd alert on in production.
Show solution
Haverun_agent call tracer.record() on every step and return the tracer alongside its stop reason; convert the recorded spans into the {'kind','tool','text'} shape the evaluator reads; then call evaluate() on it. Alert on: rising cap-hit rate, falling trajectory pass rate, any dangerous-tool block, and budget (token/time) p95 creeping up. That triad — bounded, observed, evaluated — is the minimum bar for an agent you can leave unattended.
Context: Your team wants to move an agent from “demo we babysit” to “service that runs unattended and handles real customer requests.” A teammate says it works fine in testing, so it's ready.
Your task: Write a short readiness note (6–9 sentences) arguing what must be in place first, grounded in this chapter's five reliability properties.
Requirements:
- Insist on hard loop caps (steps, tokens, tool calls, timeout) so no run can spin or bill forever, and that each stop reports why.
- Require a guardrail layer at all three boundaries — input, tool, output — implemented in code, not just in the prompt.
- State the determinism reality honestly: pin the model id and tool versions, but rely on logging the full trace to replay and explain runs — you cannot make an LLM perfectly reproducible.
- Require structured tracing of every step and an automated trajectory-evaluation suite (expected tools, goal reached, within budget) that gates changes.
- Name the single metric you'd watch most closely once it's live, and why.
💡 Hint: You don't need production code — this is about the reliability argument. The next chapter (4.5) builds the production layer on top: orchestration, human-in-the-loop, security, and cost at scale.
✓ Checkpoint — you can move on when you can…
- Reliable for an agent means bounded, guarded, reproducible-enough, observable, and evaluable — a property of the harness, not the model.
- Bound every run with hard caps (steps, tokens, tool calls, timeout); stop cleanly and report why, and treat a high cap-hit rate as a red flag.
- Put guardrails in code at three boundaries — input (block bad asks), tool (block bad calls), output (filter the answer) — never rely on a prompt instruction.
- LLMs are not fully deterministic; pin the model id and tool versions, keep temperature low, and log the trace so any run can be replayed and explained.
- The trace — a structured, ordered record of every step — is the unit of agent debugging and the same data budgets enforce and evaluators score.
- Evaluate the whole trajectory (right tools, sane order, goal reached, within budget), use LLM-as-judge for the fuzzy parts, and keep a named mitigation for every failure mode.