Tracing, budgets and guardrails
See what an agent did, cap what it spends, and stop it when it loops.
Create a free account Try part of it here
Why it matters
In production an agent runs thousands of times a day with no one watching. When a user reports "it did something weird", you need to see exactly what happened. When a bug makes it loop, you need it to stop before it spends a fortune. Tracing, budgets and guardrails turn a clever demo into a service a team can run and trust.
What you'll be able to do
- Trace an agent run as nested spans: the run, each model call and each tool call
- Track tokens and cost per run, and stop at a budget
- Detect an agent stuck repeating the same call
- Plan the operational side of shipping a harness: logging, privacy, rollout and monitoring
What it covers
- Traces and spans
- Reading a trace
- Budgets
- Loop detection
- The same call, three times
- Shipping a harness
- Cost from usage
- A turn budget is the last resort
Traces and spans
After a plane has trouble, investigators read the flight recorder to see exactly what happened and when. A trace is that record for one agent run. It's made of spans: timed steps, each with a parent.
run "fix failing test" 41.2 s $0.183 ├── model call 1 3.1 s in 4,210 out 312 ├── tool read_file src/cart.py 0.01 s ├── model call 2 2.8 s in 5,060 out 158 ├── tool bash "pytest -x" 6.4 s exit 1 └── ...
Each span records its name, start and end time, its parent, and attributes: tokens, stop reason, tool name, exit code, error. Standards such as OpenTelemetry, with its conventions for generative AI, let you send these to common observability tools. Many LLM tracing products accept the same data.
What to record, and what not to:
- Do: IDs, timings, token counts, costs, tool names, stop reasons, errors, the harness and model versions.
- Careful: full prompts and tool outputs. They help debugging most, but may hold personal data, secrets or customer code. Redact, sample, restrict access, and set a retention period.
Reading a trace
A trace is a tree of timed steps. Print one and the slow part stands out straight away.
spans = [
("agent turn", 0, 8200),
(" model call", 1, 1100),
(" tool: run_tests", 1, 6700),
(" install packages", 2, 5900),
(" pytest", 2, 780),
(" model call", 1, 400),
]
total = spans[0][2]
for name, depth, ms in spans:
print(f"{name:<24}{ms:>6} ms {'#' * round(30 * ms / total)}")Without a trace you might blame the model. Here most of the turn goes to installing packages, inside one tool, and that's what you'd cache. You can't fix what you can't see.
Budgets
A prepaid phone card can't run up a surprise bill. Every run needs limits that stop it cleanly in the same way:
| budget | stops |
|---|---|
| turns | loops that never converge |
| tokens or dollars | expensive runs, such as huge files read again and again |
| wall-clock time | runs stuck waiting on slow tools |
| tool calls of one kind | for example, at most 3 deploys |
Work out cost from usage on each response. Cache reads cost much less than normal input, and cache writes a little more, so count them separately. Prices change; keep them in config, not code.
At $4 and $20 per million tokens. Long conversations resend everything each turn, which is why cache reads matter so much, and why every run needs a budget.
Show the answer
A trace with tokens per model call and the tool calls between them
Per-span tokens show where the cost came from, such as one tool output read into context fifty times.
Loop detection
A common failure: the agent calls the same tool with the same input, gets the same error, and tries again. And again. It's a fly hitting the same window. A turn budget eventually stops it, but expensively. Better: notice repetition. Keep a fingerprint of each call (tool name plus its input as sorted JSON). If the same fingerprint appears, say, 3 times in the last 10 calls, step in:
- First, tell the model in the tool result: "You've made this exact call 3 times with the same result. Try a different approach."
- If it carries on, stop the run with a clear status.
In plain words: if the agent keeps doing the same thing and getting the same result, tell it. If that doesn't help, stop it.
The same call, three times
Here's the fingerprint idea from this section. The call is the same if the tool and its input match, whatever order the input's keys came in.
import json from collections import Counter def fingerprint(tool, args): return tool + ":" + json.dumps(args, sort_keys=True) def watch(calls, window=10, limit=3): recent = [] for n, (tool, args) in enumerate(calls, 1): recent = (recent + [fingerprint(tool, args)])[-window:] if Counter(recent)[recent[-1]] >= limit: return f"step in at call {n}: {tool} repeated {limit} times" return "no loop" calls = [("read", {"path": "a.py", "limit": 10}), ("edit", {"path": "a.py"}), ("read", {"limit": 10, "path": "a.py"}), ("run", {"cmd": "test"}), ("read", {"path": "a.py", "limit": 10})] print(watch(calls)) print(watch(calls[:4]))
The third read has its keys in a different order and still counts, because the JSON is sorted first. This catches a stuck agent after a few calls, not after your turn budget and a large bill.
Shipping a harness
A restaurant tries a new dish on a few tables before it goes on the menu. Ship agents with the same care:
- Version everything: system prompt, tool definitions, model and harness code. Record versions on every trace, so you can tell which change caused what.
- Run the eval suite before each release (previous lesson).
- Roll out gradually: a small share of traffic first, and compare success, cost and complaints.
- Monitor: success rate, cost per task, turns per task, error and refusal rates, and loop stops. Alert on sudden changes.
- Plan for failure: what does the user see when the API is down, a budget is hit, or a run is stopped? Clear statuses beat silent failures.
read_file with the same input three times in its window. What should the harness do first?Show the answer
Tell the model in the tool result that it's repeating itself; stop the run if it carries on
A nudge often breaks the loop cheaply. A firm stop comes next if it doesn't.
Cost from usage
Count each kind of input token at its own rate. These prices are made up for the example.
PRICES = {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25} # $ per million tokens
def cost(usage):
return (usage["input_tokens"] * PRICES["input"]
+ usage["output_tokens"] * PRICES["output"]
+ usage.get("cache_read_input_tokens", 0) * PRICES["cache_read"]
+ usage.get("cache_creation_input_tokens", 0) * PRICES["cache_write"]) / 1_000_000
first = {"input_tokens": 300, "output_tokens": 400, "cache_creation_input_tokens": 20_000}
later = {"input_tokens": 300, "output_tokens": 400, "cache_read_input_tokens": 20_000}
print(f"first call ${cost(first):.4f}")
print(f"later calls ${cost(later):.4f}")Once the prefix is cached, later calls cost far less. That's why a stable prefix matters so much for agents.
prices = {"input": 3.0, "output": 15.0}
usage = {"input_tokens": 2000, "output_tokens": 1000}
cost = (usage["input_tokens"] * prices["input"] + usage["output_tokens"] * prices["output"]) / 1_000_000
print(round(cost, 3))Show the answer
0.021
2,000 × $3 plus 1,000 × $15 is $21 per million tokens of each, so $0.021 for this call.
A turn budget is the last resort
A building has smoke alarms and sprinklers as well as fire doors. A budget will stop a stuck agent eventually, but "eventually" might be $20 and 100 turns later. Loop detection catches it after three identical calls, and a cost limit stops expensive runs whatever they're doing. Use all of them, in layers.
Logs are full of secrets
Agent logs are unusually sensitive. Full prompts contain whatever the agent read: customer records, private code, or keys it found in a config file. So decide on redaction and a retention limit before you start logging, not after. Store the sensitive parts separately, with stricter access, and delete them on a schedule. Metadata such as token counts, tool names and timings can usually be kept for longer.
Prices belong in config
Prices change, and new models arrive. If they're hard-coded across your harness, your cost reports silently drift from reality. Keep one table in config, and record the model id on each span, so that you can recalculate old runs if you need to.
Treat prompts as code
A one-line change to a system prompt can change an agent's behavior more than a thousand lines of code. Yet people often edit prompts directly in production, with no review and no test. So keep prompts in version control. Run your eval suite on every change, and roll out in stages, just as you would with code. Include the prompt version on every trace, so that when behavior shifts, you can tell which change caused it.
When a shipped agent starts misbehaving, ask first what changed: the prompt, the tools, the model, or the data it reads. Those four cover almost every regression you'll see, and your traces should be able to answer it.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What is a span in a trace?