Lakshya

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.

11 min read2 diagramsAll 20 chapters

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 WHOLE LOOP — AND WHAT BREAKS AT EACH STEP 1SENDhistory + tools2MODEL PICKSa tool, or stops3YOU RUN ITordinary code4APPEND RESULTback to history5REPEATuntil it stops That is the entire idea. It is about twenty lines of code, and the model does not “call” anything — YOU do. The model emits text saying which tool it wants. Your code parses that, runs the function, and hands the result back as another message. STEP 1Context grows every turn, so cost grows with the SQUARE of the conversation. This is the runaway bill.STEP 2It picks the wrong tool, or none. Almost always a tool DESCRIPTION problem, not a model problem.STEP 3The tool throws. If you return a stack trace, the agent has no idea what to do next.STEP 4A 40,000-token tool result poisons every subsequent turn, forever, and you pay for it each time.STEP 5Nothing stops it. No iteration cap, no budget, no no-progress check — and it loops until someone notices.
Five steps, and four of the five failure rows are cost or control problems rather than intelligence problems. That ratio is the chapter.

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.

pythona complete agent loop, with all four stop conditions
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

TOOL DESIGN IS THE ACTUAL SKILL — THE SAME CAPABILITY, TWICE ✗ WHAT PEOPLE WRITE query_db(sql: str) “Runs a SQL query.” • The model must know your schema. It does not.• One tool that can do ANYTHING — so it does the•   wrong thing confidently, and you cannot audit it.• Returns 4,000 rows and poisons the context.• On failure: a raw driver stack trace.• → the agent retries the same broken query forever. ✓ WHAT WORKS find_orders(customer_email, status?, limit=20) “Find a customer’s orders. Use when asked about an order’s status or history.” • Named for the JOB, not the mechanism.• The description says WHEN to use it.• Bounded by construction — a limit it cannot exceed.• Returns 6 fields, not 40.• On failure: “No customer with that email. Try•   find_customer(name) first.” — a next step. THE IDEA THAT MAKES ALL SIX POINTS OBVIOUS: You are writing an API for a competent colleague who has never seen your system and cannot ask you a single question. AND THE ONE PEOPLE MISS ENTIRELY: YOUR ERROR MESSAGES ARE PROMPTS. The agent reads them and decides what to do next. A stack trace tells it nothing; a sentence naming the fix changes the outcome.
Same capability, two definitions. The left one produces an agent people describe as unreliable; the right one produces the same model behaving well.

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, not query_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 limit the tool enforces, not one you hope the model passes.
  • Make dangerous things separate and explicit. refund_order should not be a mode of update_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

What does the model actually do in an agent loop?Nothing but emit text saying which tool it wants. Your code parses that, runs an ordinary function, and appends the result as another message.
What is the framing for tool design?You are writing an API for a competent colleague who has never seen your system and cannot ask you a single question — they have only the name, description and parameter names.
Why do error messages matter so much?They are prompts. The agent reads the error and decides what to do next. A stack trace makes it retry identically; a sentence naming the fix changes the outcome.
How many tools is too many?Past roughly fifteen to twenty, selection accuracy degrades. Forty tools is a routing problem — group them or load only the relevant ones.
What are the four stop conditions?A turn cap, a token budget, no-progress detection (same tool, same arguments twice), and a wall-clock timeout. And say so when it stops unfinished.
Why does agent cost grow so fast?The whole history is resent every turn, so cost grows roughly with the square of the conversation. One large tool result is paid for on every remaining turn.
When is multi-agent actually justified?Two reasons, both about context rather than roles: isolating a sub-task that would read enormous material, and genuine parallelism over independent work. Otherwise use one agent with better tools.
Where does the human approval gate belong?On the tool, classified by reversibility — reads free, reversible writes automatic, irreversible or outward-facing always gated. And show the arguments, not the intent.
How do you test an agent?Assert the outcome, plus constraints on the path — never the specific sequence of calls. Log the full trajectory anyway; you will need it whenever something breaks.
← An eval suite, start to finishContext engineering →