InterviewPrepKit

Home / Learn / Agents & LLMs

01 — Agent Foundations

Most systems called agents are not agents. This chapter gives a test that settles the question, then builds the loop that every agent is made of.

By the end you should be able to:

The claim to be able to defend in an interview is “that is a workflow, not an agent,” backed by cost arithmetic.

The shape of the whole thing

Before any mechanism, fix what goes in and what comes out.

You give the system a goal in plain English, for example “fix this failing test.” It returns a final text answer, plus whatever its tools changed along the way.

In between, the same kind of request is sent to the model over and over. Each request carries three things:

Each response comes back as two things: a list of content blocks, and a single string called stop_reason that says why the model stopped.

What a content block is

A content block is one typed piece of a response: a small object with a type field naming what it is, plus the fields that type carries.

Two block types matter for this chapter:

One response can contain several blocks, of both kinds. Here is a two-block response — the model saying what it is about to do, then asking for it:

resp.content == [
    TextBlock(text="I'll look up the weather in Paris."),
    ToolUseBlock(id="toolu_01A", name="get_weather", input={"city": "Paris"}),
]

That is why the response is a list and not a string, and it is why the worked trace in A worked trace shows resp.content as an array.

That is the entire input-output contract: a goal in, text plus side effects out; tools + system + messages sent, content blocks + stop_reason returned. Everything else in this chapter is the code around it.

The six words, in plain language

These terms are used loosely elsewhere. Here they mean exactly this:

Two facts everything else rests on

These come from how models work.

  1. The API is stateless. The API is the network endpoint you send each request to. Stateless means the server keeps nothing between calls — so turn 20 only knows about turn 1 because you resent turn 1.

  2. You are billed per token, on every request. Resending a growing conversation on every turn is where an agent’s money goes.

Chapter 00 derives both from the mechanism; you do not need it to follow this chapter.

1. The three tiers

Most projects that call themselves agents should not be agents. Defending that claim about a real system needs three things: a ladder of tiers, a test that places any system on a rung, and the arithmetic for what the top rung costs.

The ladder

Three rungs, ordered by how much freedom you hand the model. Read left to right: at each step, more of the control flow moves out of your code and into the model.

flowchart LR
    A["Single LLM call<br/>prompt → output<br/>rung 1 · least freedom"] --> B["Workflow<br/>you write the control flow<br/>rung 2 · one step up"]
    B --> C["Agent<br/>model writes the control flow<br/>rung 3 · most freedom"]

    style A fill:#2d6a4f,color:#fff
    style B fill:#40916c,color:#fff
    style C fill:#95d5b2,color:#000

Dark green marks the tier you should have to argue your way out of; each lighter shade is a step into more unpredictability. The same convention returns in Stop conditions.

What changes between the rungs is one substitution — who computes the next step:

Comparing the three tiers

Two terms the table uses:

The cost column is in multiples of what a single model call costs. Why the cost gap is so large below derives the 5–20× rather than asserting it.

TierWho decides step N+1CostDebuggableExample
LLM callNobody. One shot.TriviallyClassify a ticket
WorkflowYou, in code. Fixed DAG., knownPer stageExtract → validate → route
AgentThe model, at runtime.5–20× a single call, unbounded aboveOnly via traces“Fix this failing test”

The dividing line is not tool use

A workflow can call tools. Tool use is not the test.

What distinguishes an agent is that the model chooses which step comes next, and when to stop. Two lines of pseudocode make the difference exact. f is whatever computes the next step, and a forward pass is one run of the model over its whole input, producing its next output:

workflow:  next_step = f(current_step)              # f is your code
agent:     next_step = model(history, tools)        # f is a forward pass

So in an agent, the function that picks the next step is not a function you can read. It is a model inference, and it runs afresh every turn.

That substitution is what buys you adaptivity and what costs you predictability. You cannot enumerate the paths, so you cannot test them exhaustively, so every guarantee has to come from invariants — properties you enforce on every path, such as “this agent can never send an email without approval.” That is chapter 07 in one sentence.

Running the rule on three real systems

Apply the substitution to three systems: one that reads like an agent and isn’t, one that genuinely sits on the line, and one that is unambiguously an agent. The middle case is what makes the definition usable, because a definition you can only apply to easy cases is not much use.

(a) A model-based router — looks like an agent, isn’t.

A call to a small, cheap model — Claude Haiku — reads an incoming support ticket and picks one of four branches: refund, technical, abuse, fallback (Routing).

A model is choosing what runs next, so next_step = model(...) seems to fit. The verdict is still workflow, for two reasons.

First, the branch set is enumerated in your code at design time. So the reachable state space — the complete set of situations the system can ever be in, and therefore the complete set of runs you would have to exercise to test it exhaustively — is four paths, all of which you can test.

Second, the model makes exactly one decision per request. After the branch is chosen, the rest of the run is fixed code.

Chapter 02’s table is right to file Routing under “Who plans: You”: you planned all four routes, and the model only picked an index into your list.

(b) An evaluator–optimizer loop — the genuinely ambiguous one.

A generator drafts an answer, a separate model acting as a judge scores the draft, and on a failing verdict the loop runs again (Evaluatoroptimizer).

Now a model decides whether there is a step N+1. It is choosing when to stop, which is half of the agent definition.

The verdict is workflow with a bounded loop. The step sequence is still fixed — generate, then evaluate, then generate. The iteration count is capped by your code. And the model chooses from a two-element set: {iterate, stop}. You cannot enumerate every possible draft, but you can enumerate every possible path, which is what testability needs.

It flips, though. Let the judge return which of several repair tools to invoke rather than a pass/fail bit, or drop the round cap so the loop ends only when the judge says so, and both halves of the definition now hold: an unbounded action space and model-owned termination.

Same diagram, same two model calls per round, different tier. Which is why you classify by what the model is allowed to choose from, not by how the boxes are drawn.

(c) “Fix this failing test,” with a shell tool — an agent.

Try to write the paths down. The model may read the test, then the implementation, then a third file it inferred from the traceback (the list of function calls the failure printed on its way out). Or it may run the test first to see the failure. Or run git log on the file to find the regression.

It stops when the test passes, which nothing in your code can predict a turn ahead. There is no finite branch set to enumerate, because the argument space of a shell command is unbounded, and the number of steps is a property of the bug rather than of your design.

The verdict is agent. Note that the tool count here is one — which is why “does it call tools” was never the question.

The rule, sharpened

Here is what you actually say in an interview:

The model choosing among branches you wrote is a workflow. The model choosing from an unbounded action space — and choosing when to stop — is an agent.

Two diagnostics fall out of it, and you can check both before writing any code:

  1. Can I draw the complete set of paths?
  2. Does my code, not the model, decide when the run ends?

Two yeses mean you have a workflow.

Run them on the tier table’s examples: “classify a ticket” has one path, “extract → validate → route” has a fixed three, and “fix this failing test” has a number nobody can write down.

Why the cost gap is so large

An agent has no server-side memory, so every turn resends the full conversation so far. That single fact is the cost model.

Two symbols. Write P for the fixed prefix that rides on every request — the system prompt plus the tool schemas — and a for the tokens each turn appends on top of it.

turn 1 sends            =  P + 1·a
turn 2 sends            =  P + 2·a
turn n sends            =  P + n·a
                           ─────────────────────────────────────────
total over n turns      =  n·P  +  a·(1 + 2 + … + n)
                        =  n·P  +  a·n(n+1)/2      ≈   n·P + a·n²/2

The middle step is the whole derivation. Every turn pays P again, so the prefix contributes n·P. The per-turn additions stack up as 1a + 2a + … + na, which is a times the sum of the first n whole numbers — and that sum is n(n+1)/2.

Which convention: this chapter charges turn 1 its own a, giving n(n+1)/2. Deriving the numbers charges turn 1 no delta, giving n(n−1)/2, which is smaller by exactly a·n. Both round to a·n²/2 and both are defensible; the numbers here are internally consistent under this one. Never mix the two inside a single calculation.

Linear versus quadratic. A workflow with N independent stages sends N·(P + a), which grows linearly — double the stages, double the tokens. An agent’s total contains an term, so it grows quadratically — double the turns, and that term quadruples.

Now substitute real numbers: a 2,000-token fixed prefix (system prompt plus tool schemas), 500 tokens added per turn, 20 turns.

sum of 1…20      =  20·21/2                                        =      210
prefix cost      =  20 · 2,000                                     =   40,000
delta cost       =  500 · 210                                      =  105,000
agent, 20 turns  =  40,000 + 105,000                               =  145,000

ONE single call  =  2,000 + 500                                    =    2,500
20 single calls  =  20 · 2,500                                     =   50,000

145,000 / 2,500   =   58×    against one call
145,000 / 50,000  =  2.9×    against twenty calls

Always say which denominator you mean, because the two answers differ by more than an order of magnitude. A 20-turn agent costs 58× a single call, which is 2.9× what twenty single calls cost. The first number compares the agent to the cheapest thing that could have answered the question at all; the second compares it to doing twenty independent pieces of work.

The tier table above quotes the first kind. 5–20× a single call is the range you get from an agent of roughly 4 to 10 turns:

4 turns:   sum 1…4  = 4·5/2  = 10    →  4·2,000 + 500·10 = 8,000  + 5,000  = 13,000  →  13,000/2,500 = 5.2×
10 turns:  sum 1…10 = 10·11/2 = 55   → 10·2,000 + 500·55 = 20,000 + 27,500 = 47,500  →  47,500/2,500 =  19×

The multiple grows with every turn, which is why the range is open-ended. At 40 turns: sum 1…40 = 40·41/2 = 820, so 40·2,000 + 500·820 = 80,000 + 410,000 = 490,000. That is 490,000/2,500 = 196× a single call, and 490,000/100,000 = 4.9× forty single calls.

Keeping P in the formula is what keeps the number honest. Drop the prefix — set P = 0 — and the 20-turn total collapses to a·n(n+1)/2 = 210a, against 20a for twenty single calls. That is 10.5×, more than three times the 2.9× above, from the same agent, purely because the fixed prefix was dropped.

Which number you land on is entirely a question of how large your fixed prefix is relative to what each turn appends. That is why the first cost lever is making P cacheable, not making the agent shorter. (Caching means the provider stores the processed form of an unchanged prefix and bills roughly a tenth of the normal rate to reuse it; Prompt caching derived derives the rules, and The agent loop below shows where the prefix sits in a request.)

This is the honest reason to prefer a workflow, and it is a much stronger argument than “workflows are simpler.”

What interviewers probe: whether you reach for an agent reflexively. “This is a workflow, not an agent, and here’s the cost arithmetic” outscores any clever agent design.

2. The agent loop

Every agent is a variation on one loop, and almost everything that goes wrong with the loop goes wrong at its exits — the endings it must branch on, and the guards that end it when the model won’t.

Start with the picture. Every box prints a short identifier before its label — S for the user goal, M for the model call, D for the stop_reason branch, E for tool execution, A for the append, G for the guards, X for the halt, F for the final answer, P for the pause. The pseudocode in Tier 1 pseudocode labels its lines with the same letters, so you can read the two side by side.

Focus on the diamond D. It has five outgoing arrows, and four of those five are branches people forget to write.

flowchart TD
    S(["S · User goal"]) --> M["M · Call model with<br/>history + tools"]
    M --> D{"D · stop_reason"}
    D -->|tool_use| E["E · Execute tools"]
    E --> A["A · Append results<br/>to history"]
    A --> G{"G · Guards pass?"}
    G -->|yes| M
    G -->|no| X(["X · Halt: budget /<br/>step cap / loop"])
    D -->|end_turn| F(["F · Final answer"])
    D -->|max_tokens| T(["T · Truncated — NOT success"])
    D -->|refusal| R(["R · Declined — content may be empty"])
    D -->|pause_turn| P["P · Paused — append<br/>and resend"]
    P --> M

    style S fill:#1d3557,color:#fff
    style F fill:#2d6a4f,color:#fff
    style X fill:#9d0208,color:#fff
    style T fill:#bc6c25,color:#fff
    style R fill:#bc6c25,color:#fff
    style P fill:#bc6c25,color:#fff

Four things to take from it.

S · User goal is the only input to the whole picture. Everything else in the diagram is state your harness carries between calls.

D · stop_reason is the string the model returns saying why it stopped. It has five branches because the table in Stop reasons you must handle lists five values you must handle. A loop coded from a four-branch diagram has the pause_turn bug built in.

Only two branches come back. tool_use goes round through execute → append → guards, and pause_turn goes straight back to the model call. The other three leave the diagram for good.

Three of the five branches are amber, and their labels say why they are dangerous.

Emptiness is not exclusive to refusal, though. Tier 3 anthropic sdk shows an end_turn arriving with no text block and taking down the success path the same way. So the habit to build is: read stop_reason before you index content, on every branch.

Why pause_turn exists

pause_turn is the only non-final branch besides tool_use, and the diagram sends its arrow straight back to Call model with history + tools — because a pause is a turn that has not finished, not a turn that ended.

It comes from a server tool: a tool the provider runs on its own infrastructure rather than in your harness. You never see a tool_use block for it and you never write code to execute it. Web search and code execution are the usual examples — you declare the tool in your request and the provider does the work server-side.

The provider is running its own little loop over there — search, then read, then search again — and that loop has an iteration cap. When it hits the cap before the model is done, you get back everything produced so far with stop_reason: "pause_turn".

To resume: append that content to the conversation as the assistant turn, and send the request again with no new user message. The provider recognises the unfinished turn and picks up where it left off.

Adding a “please continue” message instead is the mistake. It changes the conversation the model is resuming.

The guard node, and why it sits where it does

A guard is a check your harness runs between turns that can end the run regardless of what the model wants.

Two nodes deserve their own treatment — Guards pass? (G) and Halt (X) — because their placement is a real design decision.

Guards pass? runs after results are appended to history and before the next model call. That is the only point in the cycle where the run’s full cost so far is known and no further money has been committed. Put it before tool execution and you halt on a budget you have not yet spent. Put it after the model call and you have already paid for the turn that broke the cap.

The Halt node names three causes, and each is a different piece of harness state:

All three are the harness’s job, not the model’s, for the reason chapter 07 makes structural: a model asked to respect a budget it cannot observe is being asked to guess.

What actually happens on one iteration

Six things happen between one model call and the next. Steps 2 and 3 name the two phases of inference, which is where the loop’s cost and latency come from; The kv cache the most important mechanism in this chapter derives them.

  1. Serialize. Your harness turns tools, system, and messages into one token sequence — in that order. That order is why stable content must come first if it is going to be cacheable.

  2. Prefill. The whole input sequence is processed in parallel, in one pass. Processing a token produces a small bundle of numbers the model needs again for every later token, and the provider keeps those bundles in a KV cache (short for key–value cache — the two arrays that make up each bundle). If the opening span of your request matches a span the provider already processed, it reuses that stored cache instead of recomputing it, and bills you roughly 10% of the normal rate for the reused part.

  3. Decode. Output tokens are generated strictly one at a time, each one re-reading everything before it. This is why output tokens cost several times what input tokens cost, and why a long answer takes noticeably longer than a long question. It is also why streaming exists — delivering each token as it is produced instead of waiting for the whole response — which is the remedy recommended in Stop reasons you must handle.

  4. Tool request, if any. If the model wants a tool, it emits a structured tool-call block rather than prose. The provider’s decoder — the code on their side that turns the model’s output into the actual next token — forces that block to match your schema by refusing any token that would break it (Structured output is a guarantee not a request). The model does not enforce the schema; the code around it does, which is why the guarantee holds. stop_reason comes back as "tool_use".

  5. Execute and append. Your harness runs the requested calls — the diagram’s Execute tools node — and then appends the results to history as one single user message. Mistake #3 in The five mistakes with the errors they produce is what happens when you send them as several.

  6. Guard. Guards run on the now-longer history. If they pass, control returns to step 1 — the same Call model with history + tools node, but with a longer sequence. If they do not, the run halts.

Step 6 is the whole cost story. Nothing is remembered server-side; the only reason turn 20 knows about turn 1 is that you resent it.

And note that the Final answer node is reached only from end_turn. Every other exit from the diagram is a partial result in a success-shaped response object.

The four moving parts

Once the loop is in place, there are exactly four things left to design, and each has its own chapter:

3. Building it: three tiers

Here is the same loop written three times — as six lines of pseudocode, as an explicit graph in a framework, and as production code against the real API. Reading them in order shows you which lines are essential, which are library convenience, and which are the ones that break in production.

Tier 1 — Pseudocode

Six lines, one per node of the diagram in The agent loop. Read them side by side with it; the trailing comments name the node each line corresponds to.

history = [user_goal]
while True:
    reply = model(history, tools)                     # M: call model
    if reply.wants_no_tools: return reply.text        # D -> end_turn -> F
    results = [run(call) for call in reply.tool_calls] # E: execute tools
    history += [reply, results]                       # A: append, ONE message
    if over_budget() or over_steps(): halt()          # G -> X

One thing to read past first. reply.wants_no_tools collapses all four non-tool_use endings into a single branch. That is deliberate: this tier is the shape of the loop, not a loop you would ship, and the shape is “tools, or done.” Tier 3 below splits that one branch into the four the dispatch table in Stop reasons you must handle requires, and the difference between these six lines and those is almost entirely that split.

Two further details are choices rather than accidents.

history += [reply, results] appends reply whole — the assistant turn including its tool-call blocks — and then all of results as a single message. Splitting that list into one message per result is mistake #3 in The five mistakes with the errors they produce, the one that costs 3× with no error to tell you.

The guard sits at the end of the loop body, after the append. That is the G placement argued for in The guard node and why it sits where it does. Moving it above model(...) would halt the run over spend that has not happened yet.

Tier 2 — LangGraph

LangGraph is an open-source library that expresses the loop as an explicit state machine — a set of named nodes plus rules for which node runs next — instead of a while statement.

Seven of its names appear in the code below. Read these first, or the block is noise:

Two names in the code are yours rather than the library’s, and the block assumes you defined them earlier:

The value of the framework is not the abstraction. It is the checkpointer: the component that writes the graph’s state out after every node, so a run can be picked up again later. That is what buys you durable state, resumption after a crash, and human approval pauses that survive a process restart.

The block below builds the graph, wires the cycle, and runs it once:

from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver

def call_model(state: MessagesState):
    return {"messages": [llm.bind_tools(tools).invoke(state["messages"])]}

def should_continue(state: MessagesState):
    return "tools" if state["messages"][-1].tool_calls else END

g = StateGraph(MessagesState)
g.add_node("agent", call_model)
g.add_node("tools", ToolNode(tools))
g.add_edge(START, "agent")
g.add_conditional_edges("agent", should_continue, ["tools", END])
g.add_edge("tools", "agent")          # the cycle

app = g.compile(checkpointer=MemorySaver())
app.invoke({"messages": [("user", goal)]},
           config={"configurable": {"thread_id": "run-1"}})

The thread_id is the resume key: it names the conversation whose saved state should be loaded. Without a checkpointer at all, a crash at minute 39 of a 40-minute run loses everything.

But MemorySaver does not deliver that. It is an in-process, in-memory checkpointer. It survives a graph interrupt within one running process — which is what makes human approval pauses work in a notebook — and it dies with the process.

The crash survival promised above needs a durable backend that writes to disk or a database: SqliteSaver for a single host, PostgresSaver for anything running on more than one. MemorySaver appears in the block above because it imports without a database behind it. Treat it as the demo, not the design.

Tier 3 — Anthropic SDK

The loop written directly against the API through the official Python client library (its software development kit, or SDK), with no framework in between. It is short enough to write from memory, and worth being able to.

Read it top to bottom. The part that matters is the run of stop_reason checks before the tool-handling code — five branches in a deliberate order, which the notes after the block unpack one at a time.

import anthropic

client = anthropic.Anthropic()

SYSTEM = "You are a weather assistant. Answer only from tool results."

def handle_refusal(details) -> str:
    # stop_details is populated only when stop_reason == "refusal".
    cat = getattr(details, "category", None)
    return f"Declined ({cat}); nothing to retry with the same prompt."

TOOLS = [{
    "name": "get_weather",
    "description": "Get current weather for a city. Use when the user asks "
                   "about weather, temperature, or conditions right now.",
    "input_schema": {
        "type": "object",
        "properties": {"city": {"type": "string", "description": "City name"}},
        "required": ["city"],
    },
}]

def run_tool(name: str, args: dict) -> str:
    if name == "get_weather":
        return f"18C and raining in {args['city']}"
    raise ValueError(name)

def agent(goal: str, max_steps: int = 12) -> str:
    messages = [{"role": "user", "content": goal}]

    for _ in range(max_steps):                       # guard: step cap
        resp = client.messages.create(
            model="claude-opus-5",
            max_tokens=8192,
            system=[{"type": "text", "text": SYSTEM,
                     "cache_control": {"type": "ephemeral"}}],   # stable prefix
            tools=TOOLS,
            messages=messages,
        )

        if resp.stop_reason == "refusal":            # HTTP 200, content may be []
            return handle_refusal(resp.stop_details)
        if resp.stop_reason == "max_tokens":         # silently truncated
            raise RuntimeError("output truncated; raise max_tokens or stream")
        if resp.stop_reason == "pause_turn":         # server-tool turn paused, not finished
            messages.append({"role": "assistant", "content": resp.content})
            continue                                 # re-send with no new user message
        if resp.stop_reason != "tool_use":           # end_turn
            # the "" default is load-bearing: end_turn can carry no text block
            return next((b.text for b in resp.content if b.type == "text"), "")

        messages.append({"role": "assistant", "content": resp.content})

        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            try:
                out, err = run_tool(block.name, block.input), False
            except Exception as e:
                out, err = f"Error: {e}", True        # errors go back as results
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": out,
                "is_error": err,
            })

        messages.append({"role": "user", "content": results})

    raise RuntimeError("step cap exceeded")

The cache marker, and why it does nothing here

One piece of syntax first. "cache_control": {"type": "ephemeral"} on the system block is the caching marker from Why the cost gap is so large. Ephemeral here just means short-lived: the provider keeps the processed form of everything up to that marker for a few minutes and bills roughly a tenth of the normal rate to reuse it, then drops it. It is not storage you manage or can read back — you mark a boundary, and reuse either happens or it doesn’t.

At this example’s size the marker is a no-op. It is worth seeing why.

A cached prefix has to clear the model’s minimum cacheable length — the smallest prefix the provider will bother storing. That floor is 512 tokens on claude-opus-5, and the floors are not ordered by generation: 1,024 on claude-sonnet-5, 4,096 on claude-haiku-4-5 (Prompt caching the highest leverage lever).

Now measure this prefix. It is tools plus system, which render in that order:

get_weather schema, as JSON   =  279 characters
SYSTEM string                 =   59 characters
                                 ────────────────
total                         =  338 characters
338 / 4 characters per token  ≈   85 tokens        (rule of thumb, ch 00 §1)
85 / 512                      ≈   0.17  →  about a sixth of the floor

Nowhere near it under any tokenization (Tokens is where the four-characters-per-token figure comes from).

Below the floor nothing caches, there is no error, and the only symptom is cache_creation_input_tokens: 0 in the response.

Keep the marker anyway. A real agent’s dozen-odd tool schemas run to a couple of thousand tokens and clear 512 comfortably — that is the case the P in Why the cost gap is so large’s arithmetic describes. But verify rather than assume: print usage.cache_read_input_tokens on the second call, and if it is zero you are paying full price for the prefix on every turn.

Six lines carry the whole lesson

And none of them is the API call itself.

1. The stop_reason checks are ordered, and the order is the point. refusal, max_tokens, and pause_turn are all tested before the != "tool_use" fallthrough. Invert them and each becomes “not tool_use”, so each falls into the end_turn branch, and a truncated half-answer gets returned to your caller as the finished product. That is the silent-failure mode the table in Stop reasons you must handle warns about, produced entirely by a few lines sitting in the wrong order.

2. The pause_turn branch appends and continues rather than returning. This is the diagram’s P → M arrow in code: append resp.content as the assistant turn, then loop, which re-sends the conversation with no new user message and lets the provider finish the turn it was in the middle of. Without this branch, != "tool_use" catches pause_turn too and the loop returns a partial answer as though it were the final one — the exact bug Stop reasons you must handle calls the sneakiest of the five.

This agent declares only get_weather, which runs in your harness, so it will never actually pause today. The branch is here because it costs three lines now and is invisible to debug later, on the day someone adds a server tool to TOOLS.

3. A pause spends a step, because the branch is written as a continue inside for _ in range(max_steps). The counter is a lap counter, not a progress counter, and a resumed turn is a lap. Three consecutive pauses against max_steps = 3 exhaust the cap having run no tools and produced no answer, and the loop then exits through raise RuntimeError("step cap exceeded") — a true statement and a misleading diagnosis. If you use server tools, either give pauses their own counter or size max_steps knowing that some laps buy nothing.

4. messages.append({"role": "assistant", "content": resp.content}) appends the block list, not the text. resp.content holds both the text block and the tool-call blocks. Keep only .text and the tool result you send next references a call the API has no record of — mistake #1 in The five mistakes with the errors they produce, with the exact error it produces.

5. The end_turn branch’s next(...) carries an explicit "" default, and that second argument is the point. A bare next(b.text for b in resp.content if b.type == "text") raises StopIteration the moment an end_turn response contains no text block — an empty content, or a response whose only blocks are of some other type.

That is the same class of bug the refusal row in Stop reasons you must handle warns about, arriving on the success path instead: HTTP 200, a well-formed response object, and an uncaught exception out of your happy path. The default converts it into an empty answer your caller can test for — something a goal predicate (Stop conditions) can catch, which a StopIteration escaping a generator expression is not.

6. The except clause returns a string instead of re-raising. The line out, err = f"Error: {e}", True turns a tool failure into a tool_result marked is_error: true, which the model reads and can act on. Raising instead ends the run over a fixable typo — mistake #4.

One thing the code does not do: only one guard is implemented here. The for _ in range(max_steps) is the step cap. The budget ledger and the loop detector from the halt node in The guard node and why it sits where it does are yours to add. A production harness has all three, and chapter 07 builds them.

4. A worked trace

Abstract descriptions hide what matters most: the exact shape of the message array. Here is a run, two turns long, message by message, followed by the five ways people corrupt that array and the error each produces.

The question is “What’s the weather where the Eiffel Tower is?”

Turn 1 request — one message. The system and tools ride on every call and are not part of this array:

messages = [
    {"role": "user", "content": "What's the weather where the Eiffel Tower is?"},
]

Turn 1 responsestop_reason: "tool_use". Note that the response contains both a text block and a tool-use block:

resp.content == [
    TextBlock(text="I'll look up the weather in Paris."),
    ToolUseBlock(id="toolu_01A", name="get_weather", input={"city": "Paris"}),
]

Turn 2 request — you append two messages, and the array is now three long. Watch the two annotated lines: the assistant message carries both blocks verbatim, and the tool_use_id in the result matches the id the model handed you.

messages = [
    {"role": "user",      "content": "What's the weather where the Eiffel Tower is?"},
    {"role": "assistant", "content": resp.content},          # BOTH blocks, verbatim
    {"role": "user",      "content": [
        {"type": "tool_result", "tool_use_id": "toolu_01A",   # id must match
         "content": "18C and raining in Paris"},
    ]},
]

Turn 2 responsestop_reason: "end_turn", and you are done. Two model calls, one tool call, three messages.

The five mistakes, with the errors they produce

1. Appending only the text.

messages.append({"role": "assistant", "content": resp.content[0].text})   # WRONG

The tool-use block is gone, but your next message contains a tool_result referencing toolu_01A. The API sees a result for a call that never happened, and rejects the request with HTTP status 400, meaning a malformed request:

400 — tool_result block(s) provided when previous message does not contain any tool_use blocks

2. Missing a result for a parallel call. The model can request several tools in one turn. Change the question to “weather in Paris, Berlin, and Tokyo?” and the same turn comes back with three tool-use blocks in a single assistant message:

resp.content == [
    ToolUseBlock(id="toolu_01A", name="get_weather", input={"city": "Paris"}),
    ToolUseBlock(id="toolu_01B", name="get_weather", input={"city": "Berlin"}),
    ToolUseBlock(id="toolu_01C", name="get_weather", input={"city": "Tokyo"}),
]

Now suppose the Tokyo lookup throws an exception, your except block logs it and moves on, and you send back only two results:

400 — messages.2: tool_use ids were found without tool_result blocks immediately after: toolu_01C

Every tool-use block needs exactly one matching tool_result, even when the content of that result is an error string. The correct handling of that failure is {"type": "tool_result", "tool_use_id": "toolu_01C", "content": "Error: ...", "is_error": True} — the block is mandatory, and its content is where the failure goes.

3. Splitting results across messages.

for r in results:
    messages.append({"role": "user", "content": [r]})        # WRONG

This one is worse than an error — it works. Nothing is rejected. But you have shown the model a conversation in which parallel calls were answered one at a time, and the model imitates the pattern it is shown: it stops emitting parallel calls. Your agent silently gets slower over the course of a session, and no error tells you.

The slowdown factor is exactly the fan-out you gave up. Fan-out is the number of tool calls the model issues in a single turn. Three cities issued in one assistant turn cost one model round-trip and one batch of tool time; the same three issued one per turn cost three round-trips and three sequential batches. So it is here because the example has three cities. The general figure is the average number of tool calls the model used to emit per turn — a task that fanned out to eight would degrade eightfold.

4. Raising on tool failure. A ValueError propagates out of your loop and the run dies. But the model can usually fix its own bad input in one turn, and a returned message like "Error: city 'Pariss' not found. Did you mean 'Paris'?" is a better instruction than anything in your system prompt.

Retry infrastructure failures — timeouts, a service being down — in the harness; return semantic failures, the ones caused by a wrong argument, to the model.

5. No step cap. Without a step cap, a confused model loops until your budget runs out.

5. Stop conditions

A step cap is only the bluntest way for a run to end. A run can end six ways, and they are not interchangeable: how far you should trust the result depends entirely on which ending fired.

Here are the six, in decreasing order of “the model decided”:

flowchart TD
    L[Agent loop] --> N{Why did it stop?}
    N --> A["end_turn<br/>model is satisfied<br/>acceptable"]
    N --> B["Goal predicate<br/>tests pass, schema valid<br/>ideal"]
    N --> C["Step cap<br/>you under-scoped it"]
    N --> D["Token / dollar budget<br/>you under-scoped it"]
    N --> E["Loop detector<br/>a bug — report it"]
    N --> F["Human veto<br/>a person intervened"]

    style A fill:#40916c,color:#fff
    style B fill:#2d6a4f,color:#fff
    style C fill:#bc6c25,color:#fff
    style D fill:#bc6c25,color:#fff
    style E fill:#9d0208,color:#fff
    style F fill:#1d3557,color:#fff

Why did it stop? is the question a trace has to answer. The green shading is the same ladder as The three tiers: darker is the ending you should have to argue your way out of.

Reading the six leaves in rank order:

Two of the six are mechanisms rather than counters, so they need defining:

Why end_turn is weak evidence

A model does not decide to finish. It produces one token at a time, and it produces each one by scoring every word-piece it could say next and picking from that spread of scores. One of the things it can pick is a special piece that means this response is over. end_turn is the API telling you that is the piece that came out.

So end_turn is a statement about which token the model chose, not about the state of the world. It correlates with task completion because of how the model was trained — responses that were genuinely finished tended to end there — but nothing enforces the correlation, and when it breaks, the failure is silent and confident.

Prefer a predicate your harness can check. Left column: what people settle for. Right column: the mechanical replacement.

Weak stopVerifiable replacement
“The model says it’s done”pytest exits 0
“It produced a summary”Output validates against the schema
“It found the answer”Every claim carries a citation that resolves
“The queue is empty”The queue is empty and the goal predicate passes

That last row is the trap. An empty task queue with an unmet goal is a failure, and an agent that reports it as success is worse than one that crashes — a crash gets investigated.

Stop reasons you must handle

Read this as a dispatch table. Every row is a branch your loop needs; the third column is the bug you ship without that branch; the fourth is what the branch body should do.

stop_reasonMeansIf you ignore itWhat to do
end_turnModel finishedCheck a goal predicate before returning
tool_useWants a toolLoop never advancesExecute, append results, continue
max_tokensHit the output capSilently truncated output read as completeRaise, or stream — never report success
refusalSafety classifier declinedresp.content[0] raises IndexError — content may be emptyBranch on stop_reason before touching content; stop_details carries the category (and is None for every other stop reason)
pause_turnA server tool — one the provider runs on its own machines, like web search — hit the provider’s own iteration cap mid-turn (The agent loop)Loop exits early with a partial answer, no errorAppend resp.content as the assistant turn and re-send with no new user message — the server resumes from the pause

Five rows, not five stop reasons in total. The API documents six. The sixth is stop_sequence, and it is absent here because it cannot occur unless you asked for it: it means the model emitted one of the strings you passed in stop_sequences, and if you never pass that parameter — this chapter’s loop does not — you will never see the value. If you do pass one, treat it as an end_turn that stopped where you told it to, and read stop_sequence on the response to learn which string fired.

Every row in the table above is something the provider can hand you unbidden, which is why those are the five your loop has to branch on.

The refusal row is worth one extra note: it comes from a separate safety model screening the exchange, not from the model declining in prose. That is why the refusal arrives as a stop_reason and can leave content empty rather than filled with an apology.

The last three rows are the ones people don’t code for, and all three fail quietly. pause_turn is the sneakiest: the response is well-formed, content is populated, and the only thing wrong with it is that the model was not finished.

6. Reactive vs. proactive

Everything so far assumed a person watching the run. Remove the person (an agent triggered by a schedule rather than a message) and one failure is common: a message delivered twice to an agent that produces something different each run.

A reactive agent runs because a user sent a message. A proactive agent runs because a clock, a webhook — a request another system makes into yours the moment something happens there — or a file watcher fired.

The table contrasts them on the four dimensions that change your design; the bottom-right cell is where all the new work lives.

ReactiveProactive
TriggerUser messageSchedule, webhook, watcher
Failure seen byThe user, immediatelyNobody, for hours
Latency budgetSecondsMinutes to hours
NeedsFast first tokenDurable state, retries, alerting, idempotency

A proactive agent is a distributed system with an LLM in it. It needs everything an ordinary scheduled job needs:

And then non-determinism on top, meaning the same input does not reliably produce the same output.

What that looks like on a real system

A nightly job wakes at 02:00, pulls unanswered support tickets off a queue, and for each one runs an agent that reads the ticket, searches the docs, drafts a reply, and sends it.

Nothing about that agent’s loop differs from The agent loop — same stop_reason branch, same guards. What differs is the trigger. A failure at 02:04 is discovered at 09:00 by a customer, and there is no user sitting there to notice that the agent answered the wrong ticket.

The specific hazard: at-least-once delivery meets a non-idempotent agent

The collision, step by step:

  1. A worker takes ticket #8412, drafts a reply, and sends it.
  2. The worker dies before acknowledging the message.
  3. The queue’s visibility timeout — the window during which a handed-out message is hidden from other workers — expires with no acknowledgement.
  4. The ticket is redelivered. A second worker runs the same agent.
  5. The customer gets two emails.

The fix is the same as in any distributed system: an idempotency key, a stable identifier for one unit of work, checked against a ledger before the irreversible act, and written to that ledger before the act rather than after it.

Where the LLM changes the usual answer

The hard part in a non-deterministic system is deciding what the key is derived from.

Derive it from the input, never from the output. The same ticket run twice produces two different drafts — different wording, different length, possibly a different subject line. So a key computed from the generated email is a different key on every retry, and the ledger becomes a growing log of duplicates that never recognizes a repeat.

Compute it from the ticket id and the ticket fields put into one fixed normalized form instead, deliberately excluding anything that varies per delivery: received_at, attempt, queue_message_id.

And compute it in the harness rather than asking the model for one. A model-generated key is just a token sequence with no stability guarantee across runs, which defeats the entire purpose.

Case study 02 implements exactly this, ledger states included.

7. Harness engineering

The harness is the code around the model, and most agent quality lives in that code, not in the prompt. Every one of its responsibilities exists because no prompt instruction can substitute for it.

The diagram below expands one turn of the loop from The agent loop into its parts. Everything inside the box boundary is code you write; the three dotted arrows are state that no single step owns.

flowchart LR
    subgraph H["Harness — you own this"]
        P[Prompt assembly] --> API
        API[Model call] --> R[Response parsing]
        R --> AUTH{Authorize}
        AUTH --> T[Tool dispatch]
        T --> C[Context management]
        C --> P
        B[(Budget<br/>ledger)] -.-> API
        L[(Trace log)] -.-> API
        G[(Loop guard)] -.-> AUTH
    end
    M((Model)) <--> API

    style H fill:#f8f9fa,stroke:#333
    style M fill:#1d3557,color:#fff
    style AUTH fill:#2d6a4f,color:#fff

Follow the solid cycle once:

Then control returns to prompt assembly, which is why the cycle closes rather than terminating.

The three dotted arrows are not decoration. The budget ledger and the trace log attach to the model call, because that is the only place tokens are spent and the only place there is anything to record.

The loop guard, though, attaches to Authorize rather than to the model call, and that placement is an argument: a loop is detected from the (tool, args) a model asked for, so you want to catch it at the point where a repeated request can still be refused — before the tool runs, not after you have paid for the turn that requested it.

Authorize is the only highlighted node, because it is the one row in the table below that is a hard boundary rather than an optimization. Everything else in the loop makes the agent cheaper, faster, or more debuggable; authorization is what makes a wrong decision not matter.

Each row below is a harness responsibility and the mechanical reason a prompt instruction cannot cover it:

ResponsibilityWhy it can’t live in the prompt
Context assembly and orderDetermines the cache hit rate — how often the unchanged prefix gets reused instead of reprocessed. A mechanical property, not a preference
AuthorizationA prompt rule is advisory; 1% non-compliance on a destructive action is unacceptable
Retry policyThe model can’t distinguish a 503 — the server being temporarily down, worth retrying — from a bad argument, which is not
Budget accountingThe model cannot see its own token spend
Loop detectionRequires state across turns that the model doesn’t reliably track
TracingYou need it after the fact, when the model isn’t running

A better model with a bad harness loses to a worse model with a good one. Concretely: a harness that lets tool output flood the context will hit quality collapse around turn 30 regardless of which model you use. The failure is positional — models retrieve facts reliably near the start and end of their input and unreliably from the middle (Why quality degrades in long contexts) — rather than being about intelligence.

What interviewers probe: “Your agent is worse in production than in the demo. Where do you look first?” Weak: prompt tuning. Strong: pull traces; check cache_read_input_tokens, the usage field counting how many input tokens were served from cache (a deploy may have changed one byte of the cached prefix and dropped it to zero), the stop_reason distribution (a rise in max_tokens means silent truncation), tool error rates, and whether the search index the agent retrieves from was rebuilt. Prompt tuning is what you do after the diff tells you where it diverged.

8. When not to build an agent

Whether a system deserves an agent at all comes down to four checks. Run all four; one “no” means drop a tier.

CheckAskIf no
ComplexityIs the task hard to fully specify in advance?Write the flowchart — it’s a workflow
ValueDoes the outcome justify 5–20× a single call’s tokens and the quadratic growth?Single call, or workflow
ViabilityIs the model actually good at this task type today?Ship the human process; revisit
Cost of errorCan mistakes be caught and reversed — tests, review, undo?Add a verification layer first, then reconsider

Running the checks on one system

Take a real pipeline: “Invoice arrives → extract fields → validate totals → route to an approver.”

Complexity — no. Every invoice takes the same four steps, and the variation is in layout, not in procedure. The extract stage handles that layout variation with templates: one small per-vendor rule set saying where on the page each field sits, matched by whatever the invoice looks like. It is fully specifiable, so you drop a tier.

Value — no. Price both sides in the same unit: multiples of one single call. The workflow is four single calls, so . A roughly 10-turn agent doing the same job is 19×, from Why the cost gap is so large’s arithmetic. That is 19 / 4 = 4.75, a little under five times the spend for the same output. Drop a tier again.

Viability — yes. Extracting fields from documents is something models are reliably good at today.

Cost of error — yes. A wrong total is caught by the validation stage or by the human approver, and nothing has been paid out.

Two “no”s, and the first one is load-bearing: the answer is a workflow.

Notice which check failed, though, because it names the escape hatch. The failure was complexity, and complexity is a property of the input rather than of the task type. So the roughly 5% of invoices whose layout the templates cannot parse are exactly the population that fails check 1 on their own, and exactly the population an agent should get. The check that fails tells you where the agent belongs.

The disguised-workflow trap

The most common startup interview prompt describes a fixed four-step pipeline and asks you to “design an agent for it.” The correct answer is to refuse the framing:

“Steps one through four are the same for every input, so that’s a DAG. I’d build a workflow with one LLM call per stage — cheaper, individually testable, and each stage’s failure is attributable. Then an agent escape hatch for the ~5% of inputs the pipeline can’t classify, where reasoning over an unusual case is genuinely needed.”

Case study 08 works this through end to end and shows the pipeline coming out ~27× cheaper on model spend and more accurate — because a deterministic path has no opportunity to be creative.

Cheat sheet

Each row is a symptom you will actually observe, the mechanism in this chapter that produces it, and the fix. Two fixes below are named rather than explained here: offloading means keeping bulky content out of the conversation and passing the model a pointer to it, and compaction means replacing a run of old turns with a short summary of them.

SymptomMechanismFix
400 on tool_resultDropped tool_use blocks from historyAppend the whole resp.content
Agent stopped calling tools in parallelResults split across messagesAll results in one user message
Cost grows faster than turn countQuadratic history resendCache; offload; compact
Quality collapses ~turn 30Positional recall degradationRestate goal late; compact
Run dies on a bad argumentHarness raised instead of returningis_error: true, let the model fix it
“Done” but the work isn’tend_turn used as the stop conditionHarness checks a goal predicate
Output cut mid-sentencestop_reason == "max_tokens" unhandledStream; raise max_tokens; never report success

Next: 02 — Design Patterns — the nine architectures, each with its cost derived rather than asserted.