LLM Systems Engineering · Chapter 16 of 20
Building an agent, start to finish
The loop in twenty lines, why tool design is the whole skill, how to stop it running away, and the honest case against multi-agent.
The site already covers what an agent is and where its risk concentrates, what happens at the token level, and what a managed runtime sells you. This one builds one, and spends most of its length on the part that actually determines whether it works.
The loop, written out
There is less here than the word ‘agent’ implies. The model never calls anything — it emits text saying which tool it wants, your code parses that, runs an ordinary function, and appends the result as another message.
def run_agent(task: str, tools: dict, max_turns: int = 12, token_budget: int = 200_000):
messages = [{"role": "user", "content": task}]
spent = 0
for turn in range(max_turns):
r = client.messages.create(
model="claude-opus-5", max_tokens=8000,
system=SYSTEM, tools=[t.schema for t in tools.values()],
messages=messages,
)
spent += r.usage.input_tokens + r.usage.output_tokens
messages.append({"role": "assistant", "content": r.content})
if r.stop_reason != "tool_use":
return r # it is finished
# Every tool_use block in ONE response must be answered in ONE user
# message. Splitting them teaches the model to stop calling in parallel.
results = []
for block in r.content:
if block.type != "tool_use":
continue
try:
out = tools[block.name].run(**block.input)
except Exception as e:
# An error is a PROMPT. Say what went wrong AND what to try.
out, err = f"{e}. {tools[block.name].recovery_hint}", True
else:
err = False
results.append({
"type": "tool_result", "tool_use_id": block.id,
"content": truncate(out, 4000), # never let one result flood context
"is_error": err, # do NOT silently drop failures
})
messages.append({"role": "user", "content": results})
if spent > token_budget:
return stop("token budget exhausted", messages)
return stop("hit max_turns without finishing", messages)Every framework you will meet is this, plus conveniences. Worth writing once by hand, because when an agent misbehaves the diagnosis is almost always in this loop — and it is much harder to see through three layers of abstraction.
Tool design is the whole skill
The framing that makes the rules obvious: a tool is an API for a competent colleague who has never seen your system, cannot ask you a question, and has only the tool's name, its description and its parameter names to go on.
- Name it for the job, not the mechanism.
find_orders, notquery_db. The model matches on intent, and mechanism names give it nothing to match against. - Say when to use it — and when not to. This is the single highest-return sentence you will write. If an agent picks tools badly, the description is the first place to look and almost the last place people check.
- Few tools, not many. Past roughly fifteen to twenty, selection accuracy degrades noticeably. If you have forty, you have a routing problem — group them, or use a tool-search mechanism so only the relevant ones are loaded.
- Return the six fields that matter, not the forty the row has. Everything you return is paid for on every subsequent turn, not just this one.
- Bound it by construction. A
limitthe tool enforces, not one you hope the model passes. - Make dangerous things separate and explicit.
refund_ordershould not be a mode ofupdate_order. A tool you would want an approval gate on should be its own tool, so the gate has something to attach to.
Your error messages are prompts. This is the most under-taught idea in agent engineering.
The agent reads the error and decides what to do next. It is the only feedback it gets.
Useless: KeyError: 'customer_id' — the agent retries the identical call, and does it again.
Useful: “No customer found with that email. Use find_customer(name) to look up their ID first, then retry.” — the agent does exactly that.
Write your tool errors the way you would write a message to a new colleague: what went wrong, and what to do about it. A day spent on error strings routinely outperforms a week of prompt tuning.
Stopping it
An agent with no stop condition is an unbounded loop with a credit card. You want four, and the loop above has all of them:
- A turn cap. Blunt and essential. Ten to fifteen for most tasks.
- A token budget. The real ceiling, because turns vary enormously in size.
- No-progress detection. The same tool with the same arguments twice in a row means it is stuck — interrupt it and say so, rather than waiting for the turn cap.
- A wall-clock timeout, for anything user-facing.
And when it stops without finishing, say so. An agent that hits its cap and returns a confident summary of incomplete work is far worse than one that reports it ran out of turns.
Context is a budget, not a container
Step 1 of the loop resends the entire history every turn, so cost grows roughly with the square of the conversation — see the cost chapter. In an agent this is not a rounding error; it is the dominant term.
- Truncate tool results at the boundary, as in the loop above. One 40,000-token file read is paid for on every remaining turn.
- Return references, not payloads. ‘Wrote 4,200 rows to
/tmp/out.csv; columns are X, Y, Z’ beats returning the rows. The agent can read the file if it needs to. - Drop or summarise old tool results. Turn twelve rarely needs the full output of turn two. Providers now offer context editing and compaction for exactly this.
- Keep the system prompt and tool list byte-stable, so the cached prefix survives. Tools render at position zero, so changing the tool list mid-run invalidates everything.
Multi-agent, and the honest case against it
Most multi-agent systems should be one agent with better tools
The pattern is fashionable and it is usually a more expensive way to get a worse result. Splitting one agent into five gives you five context windows that cannot see each other, hand-offs that lose information, five times the token spend, and a failure that is much harder to trace.
“A researcher agent, a writer agent and a reviewer agent” is usually three prompts and a loop, not three agents.
When it genuinely helps — and both reasons are about CONTEXT, not about roles:
1. Isolation. A sub-task that would read enormous amounts of material can run in its own context and return a summary, keeping the main agent's window clean. This is the strongest reason and it is a real one.
2. Genuine parallelism. Twenty independent files to analyse, no shared state. Fan out, collect, synthesise.
If your reason is neither of those — if it is that the diagram looks better with boxes labelled by job title — use one agent and write better tools.
Humans in the loop
The gate belongs on the tool, not on the agent. Blocking every turn for approval defeats the point; blocking nothing means a model decides to issue a refund.
- Classify tools by reversibility, not by importance. Reads: free. Reversible writes (a draft, a ticket): auto. Irreversible or outward-facing (send, pay, delete, publish): always gated.
- Show the arguments, not the intent. ‘Refund order 8812, ₹14,500’ is reviewable. ‘The agent wants to process a refund’ is not.
- An approval is for one call, not a session. Approving once must not authorise the next nine.
Testing it
Test the outcome, not the path. There are many valid routes to a correct answer, and asserting a specific sequence of tool calls gives you a suite that breaks every time the model gets better at its job.
- Outcome — did the ticket get the right priority? Is the file correct? This is the real assertion.
- Constraints on the path, not the path itself — it must not have called
send_email; it must have finished inside eight turns; it must not have exceeded the budget. - Log the full trajectory anyway. You will not assert on it, and you will need it every single time something goes wrong.
- The suite in the eval chapter applies directly — the only difference is that an agent's run has a cost and a turn count you should also be asserting on.
What goes wrong
- Vague tool descriptions, then blaming the model for picking badly.
- Stack traces returned as tool errors, so the agent retries identically until the cap.
- Too many tools. Forty tools is a routing problem wearing a capability costume.
- Unbounded tool output flooding the context and quadrupling the bill.
- No stop conditions, discovered via the invoice.
- Multi-agent for a problem one agent would have solved, at five times the cost.
- A broad service account. The agent acts with its permissions, not the asker's — so anyone who can influence it, including a document it reads, has borrowed that account. See the risk chapter; this is the one that turns a bug into an incident.
The one-sentence version: the loop is twenty lines and it is not the hard part — spend your effort on tool names, tool descriptions, what tools return, and what your errors say, because those four are what the model actually reasons over; then bound it with a turn cap, a token budget and a no-progress check, gate the irreversible tools, and test the outcome rather than the route.
Check yourself