AI EngineeringZero to ProductionHome·About·What’s new·Contact
Part III · Chapter 4

Build an Agent With Tools

An agent is a model that can act — it calls tools you provide, sees the results, and decides what to do next, in a loop. You'll build the loop by hand so you control every step, then add the safety rails that separate a demo from something you'd run in production.

⏱️ ~2 hours🧪 5 labs🎯 Intermediate→Advanced
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the agent-loop code):Either way, keep the key in a .env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Define tools with JSON-schema inputs the model can call.
  • Write the agentic loop manually and explain every iteration.
  • Handle multiple and parallel tool calls correctly.
  • Add human-in-the-loop gates, step caps, and input validation.
  • Give the agent memory that persists across turns.

What an agent actually is intermediate

The word "agent" has been stretched to mean almost nothing, so let's start with a moment that made it click for me. The first time I watched a model "use a tool," I expected magic — some special channel where the model reached out and touched the world. What actually happened was almost disappointing: the model sent back a little chunk of JSON that said "I'd like to call get_weather with city=Tokyo," and then it stopped and waited. My own code read that JSON, ran an ordinary Python function, and typed the answer back into the conversation like another message. That was it. The "agent" was a for loop I wrote, and the model was a very good suggestion engine sitting inside it. Once you see that the model never actually does anything — it only ever asks — the whole topic stops being mysterious and starts being engineering.

Here's the analogy that keeps you honest. Think of the model as a brilliant consultant on the phone who can't leave the room. They can reason, plan, and tell you exactly what to do next — "call this number, look up that record, delete that file" — but they have no hands. You are the hands. Every real action happens because your code chose to carry out the consultant's request. That framing matters because it tells you precisely where safety, validation, and control live: not in the model, but in the loop around it. The model proposes; your code disposes.

So, stripping away the hype: an agent is a while loop around the same API call you already know. The only new idea is tools — functions you describe to the model. When the model wants to use one, it doesn't run it (it can't); it asks you to by returning a tool_use block. Your code runs the function and feeds the result back. Repeat until the model is done. That cycle — think, act, observe — is the entire definition: the model thinks about what it needs, asks to act via a tool, and you feed back what it should observe, then it thinks again with that new information in hand.

The mistake almost everyone makes first is reaching for an agent when a plain workflow would do. If the steps are known in advance — retrieve, then summarize, then format — you don't need a model deciding the order at runtime; you need a script, and a script is cheaper, faster, and far more predictable. An agent earns its keep only when the path is genuinely unknown: when the next step depends on what the last step returned, when the model must choose among tools, when the task can't be flowcharted ahead of time. The callout below is the four-question filter to apply before you write a single line — reach for it every time, because "should this be an agent?" is a design decision, not a default.

the model proposes; your code disposes — the model never touches the world itself Model reasons & plans "I'd like get_weather" Your code runs the function the only "hands" The world APIs · files · DB tool_use acts result observe then it thinks again — repeat The model asks; your code acts. A tool call is just JSON the model returns and then waits on — only your code reaches the world and runs the real function, which is exactly why safety, validation, and control live in the loop, not in the model.
🗺️ How to read this diagram

Read left to right, then follow the return arrows back. The three boxes are the only actors: the model (purple), your code (white), and the world it can affect (green).

  • The solid blue arrow from Model → Your code is labelled tool_use: the model doesn't do anything, it emits a little block of JSON saying which function it wants, then stops and waits.
  • Your code is the only box that touches the world — the solid "acts" arrow into the green box is the real function actually running. The model has no arrow of its own into the world; that absence is the whole point.
  • The dashed arrows are the results flowing back: the world hands data to your code, your code feeds it to the model as an observation, and the bottom dashed loop shows the model thinking again with that new information — round and round until it's done.

In short: the model proposes a tool call and waits; your code disposes by running it and reporting back. Because only your code has hands, that loop is where you put every guardrail.

Should this even be an agent?Before building one, check all four: Complexity (multi-step, hard to script?), Value (worth the cost/latency?), Viability (is the model good at this?), Cost of error (recoverable?). If any answer is "no," a plain workflow (Ch 2–3) is better. Agents are powerful and expensive — use them when the task genuinely needs autonomy.

The agentic loop, visualized intermediate

If you remember one picture from this whole chapter, make it this one. Every agent framework you'll ever meet — LangChain, the Claude Agent SDK, some 2,000-line orchestration engine at work — is, underneath all the abstraction, the little diagram below. People assume there must be more to it, and there isn't. When an agent misbehaves in production, the debugging always comes back to "which box are we in, and why did the arrow go the wrong way?" Learn the loop by hand here and you'll be able to read any framework's source without flinching.

The everyday analogy is a conversation with a capable assistant who keeps checking back in. You give them a goal. They either hand you the finished answer ("here's your report — done") or they come back with a question that requires you to go do something ("what's in that file?"). If it's a question, you go get the answer, hand it over, and they carry on. You repeat that exchange as many times as the task needs, and it ends when they say they're finished. The agent loop is exactly that exchange, formalized: call the model, read one field to see whether it's done or wants a tool, act if needed, repeat.

The one field that runs the whole show is stop_reason. After every model reply your code asks a single yes/no question of it: did the model finish (end_turn), or does it want a tool (tool_use)? That's the fork in the road, and it's genuinely the only decision the loop makes. The classic beginner bug is to inspect the text of the reply and guess — "it said the word 'search', so I'll search" — instead of trusting stop_reason. Don't. The model tells you its intent structurally, in that field; read it, branch on it, and never try to parse intent out of prose. Trace the arrows below clockwise and you've understood every agent you'll ever build.

Call model with full history stop_reason? end_turn → done return answer tool_use → run tool(s) execute, append results done tool loop back
🗺️ How to read this diagram

This is the whole idea of an agent in one picture: a loop. Follow the arrows clockwise starting from the top box. Each trip around is called a step.

  • Call model (with full history) — the top purple box. Every step you send the model the entire conversation so far, plus the list of tools it's allowed to use. The model reads it and replies.
  • stop_reason? — the middle box is the decision point. After each reply you look at one field, stop_reason, to decide what happens next. There are exactly two outcomes.
  • end_turn → done (green, right) — the model says "I'm finished." You take its text answer and leave the loop. This is the normal, happy exit.
  • tool_use → run tool(s) (teal, bottom) — instead of a final answer, the model is asking you to run a tool. You execute the real function, append the result, then follow the loop back arrow up to "Call model" and go around again.
  • The loop back arrow (bottom-left) is the heart of it: run a tool, feed the result in, and the model gets another turn — as many times as it needs.

In short: the model never runs a tool itself — it only asks. Your code does the running and hands the result back. You repeat that hand-off until the model says end_turn (or your step cap stops it).

Two exits: the model says it's finished (end_turn), or your step cap trips. Everything in between is: model asks for a tool → you run it → you give back the result → model continues.

Lab 4.1 · Define a tool intermediate

Imagine hiring a sharp new teammate on their very first day. They're capable and eager, but they know nothing about your systems. You hand them a one-line note for each thing they're allowed to do: what it's called, when to reach for it, and exactly what information you need from them to do it. If your note is vague — "handles stuff" — they'll either never use the tool or use it at the wrong moment. If it's precise — "use this whenever a customer asks about a refund; I need the order ID and the amount" — they'll get it right. A tool definition is that note. The model is the new teammate, and everything it knows about your tool is what you wrote in the definition. It cannot see the function body, your comments, or your intentions — only the description and the schema.

That's why this deceptively small block is worth slowing down on. A tool is three things: a name (the identifier the model uses to ask for it), a description (prose telling the model when to use it), and an input_schema (a JSON schema declaring the arguments and their types). The name and schema are plumbing — easy to get right. The description is where the real skill lives, and it's where people under-invest most.

The common mistake is writing the description as documentation instead of as instructions. "Gets the weather for a city" tells the model what the tool is, which it can mostly infer from the name anyway. What it can't infer is the when: should it call this for "is it cold in Paris?" Yes. For "what should I pack for Paris?" Also yes — but only if your description hints that weather is relevant to packing. Modern models are deliberately conservative about calling tools (an unnecessary call costs time and money), so a description that spells out the triggering situations — "Call this whenever the user asks about weather, temperature, or conditions in a specific place" — measurably raises how often the model reaches for it at the right moment. Think trigger conditions, not a dictionary entry. And treat the JSON schema as a contract, not a suggestion: "required": ["city"] genuinely forces the model to supply a city, and an enum constrains it to legal values, so a well-typed schema prevents a whole class of malformed calls before they happen.

a tool = three fields the model reads · the function body is hidden from it Tool definition (model reads) name — "get_weather" description — WHEN to call it input_schema — typed args get_weather(city, unit) the real function your code runs it — hidden pairs with The description is what the model knows. A tool is a name, a when-to-use description, and a typed input_schema — the model reads only those three; it never sees the function body, so the description carries all the intent.
🗺️ How to read this diagram

Read the left card top to bottom — it's the dictionary the model receives — then follow the dashed arrow to the real function on the right.

  • The three stacked rows inside the left box are the tool's only fields: name (the identifier the model asks for), description (the amber row — when to use it, the field that carries the intent), and input_schema (the typed arguments, with required forcing the model to supply them).
  • The purple box on the right is the actual Python function, get_weather — the code your loop runs. It is deliberately outside the left card because the model never sees it.
  • The dashed "pairs with" arrow is the whole relationship: definition and function are two halves of one tool — one tells the model about it, the other does the work.

In short: the model decides whether and how to call your tool purely from the name, description, and schema — so a sharp "when to use it" description is where the reliability comes from.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab 4.1

Defining a tool — in Claude or OpenAI

A tool definition is a dict describing the function to the model. The fields are nearly identical; the only differences are the schema key — Claude nests the JSON schema under input_schema, OpenAI puts it under parameters and adds a "type": "function" tag. The real Python function underneath is the same. Toggle the tab to switch SDK — your choice applies across the whole page.

tools.pyWEATHER_TOOL = {
    "name": "get_weather",
    "description": (
        "Get the current weather for a city. "
        "Call this whenever the user asks about weather, temperature, "
        "or conditions in a specific place."          # WHEN to use it
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type":"string", "description":"City name, e.g. 'Paris'"},
            "unit": {"type":"string", "enum":["celsius","fahrenheit"]},
        },
        "required": ["city"],
    },
}

def get_weather(city, unit="celsius"):        # the actual implementation
    # In real life: call a weather API. Here, fake it.
    return f"18°{'C' if unit=='celsius' else 'F'} and cloudy in {city}"
▶ How this works

A tool is just a function you let the model use — but first you have to describe it so the model knows it exists. This block does both: it writes the description (a plain Python dictionary) and the real function underneath. The model reads the description; your code runs the function.

  1. The dictionary has three parts. "name" is the tool's identifier ("get_weather"). The model uses this exact name when it wants the tool.
  2. "description" is the most important part — it tells the model when to reach for this tool ("whenever the user asks about weather…"), not just what it does. A good when makes the model call the tool at the right moments.
  3. "input_schema" describes the arguments in JSON schema — a standard way to say "this tool takes a city (a string) and an optional unit that must be celsius or fahrenheit." "required": ["city"] means the model must supply a city.
  4. def get_weather(city, unit="celsius") is the actual function that runs. Here it returns a fake sentence; in real life it would call a weather service. The description and the function are a pair: one tells the model about the tool, the other does the work.

What the output means: Nothing prints yet — this block only defines the tool and its function. You wire it into the agent loop in the next lab, and that's when it actually gets used.

Try this: Rewrite the "description" to be vague (just "Gets weather") and, after Lab 4.2, notice the model calls it less reliably. The description is how the model decides — treat it like instructions to a new teammate.

Description = when, not just whatModern models call tools more conservatively. A description that says when to reach for the tool ("Call this whenever the user asks about weather…") measurably improves correct usage over one that only says what it does.

Lab 4.2 · Write the loop intermediate

This is the code I wish someone had shown me before I ever touched an agent framework. It's about twenty lines, and it's the entire beating heart of every agent — once you've read it, the frameworks stop being intimidating and become "oh, that's just this, with more logging." So read it slowly; this one lab pays for the rest of the chapter.

The whole thing is a loop with four moves you already understand from the diagram: call the model, check if it's done, run any tools it asked for, feed the results back. Everything else in the code is bookkeeping in service of those four moves. If you keep that skeleton in your head — call, check, run, feed — the details slot into place instead of feeling like ceremony.

Two details are load-bearing, and both are where beginners quietly break their agent. First: after the model asks for a tool, you must append the model's turn to the history verbatim before you add the tool results. The tool_use block has to physically be in the conversation, or the next API call rejects your tool_result as an orphan referring to something that isn't there. Second: every tool_result you send back must carry the exact same tool_use_id the model put on its request — that id is the thread that ties "here's my answer" to "the question you asked." Get either wrong and you'll see a confusing 400 error that has nothing to do with your logic. The walk below narrates the code line by line; the warning box after it collects the three mistakes people make so you can recognize them on sight. One more thing that isn't optional: notice MAX_STEPS and the for step in range(MAX_STEPS) that bounds the loop. An agent without a hard step cap can loop forever — model asks for a tool, gets a result, asks again, on and on — quietly burning real money. Never, ever write an unbounded agent loop; the cap is a seatbelt, not a nicety.

four moves per step, bounded by MAX_STEPS so the loop always terminates 1 · Call model history + tools 2 · stop_reason? done, or tool? 3 · Run tools append turn, then dispatch each 4 · Feed all results ONE message tool end_turn → done return text, exit done loop back — up to MAX_STEPS times, then stop Call · check · run · feed. Every step is those four moves; the two load-bearing details are appending the assistant turn verbatim before results and batching all results into one message, and the whole thing is capped by MAX_STEPS so it can never spin forever.
🗺️ How to read this diagram

Read the top row left to right — those are the four moves of a single step — then follow the arrows down for the two ways a step ends.

  • 1 · Call model → 2 · stop_reason? Every step sends the full history and the tool list, then reads one field to branch. That single field, stop_reason, is the only decision the loop makes.
  • 3 · Run tools → 4 · Feed (the amber path, taken when stop_reason is tool_use): first append the model's turn verbatim, then dispatch each requested tool, then send all results back in one user message. Those two details are where beginners break their agent.
  • The green box is the happy exit: end_turn means the model is finished, so you return its text and leave.
  • The dashed "loop back" arrow returns to move 1 — but only up to MAX_STEPS times. That cap is the seatbelt that stops a runaway loop from burning money forever.

In short: call, check, run, feed — repeat until end_turn or the step cap trips. Append verbatim and batch results in one message, and the loop just works.

Lab 4.2

The agent loop — in Claude or OpenAI

The four moves — call, check, run, feed — are identical across both SDKs; only the protocol names differ. In Claude the model returns tool_use blocks you match by tool_use_id, you branch on stop_reason, and send tool_result blocks back in one user message. In OpenAI the model returns function_call items in resp.output (the arguments are a JSON string you json.loads), you match by call_id, there's no stop_reason so you loop while any function_call remains, and you feed each result back as a function_call_output item. Toggle the tab to switch SDK.

agent.pyfrom anthropic import Anthropic
from tools import WEATHER_TOOL, get_weather
client = Anthropic()

TOOLS = [WEATHER_TOOL]
DISPATCH = {"get_weather": get_weather}     # name -> function
MAX_STEPS = 6                              # hard cap: the loop MUST terminate

def run_agent(user_input):
    messages = [{"role":"user", "content": user_input}]

    for step in range(MAX_STEPS):
        resp = client.messages.create(
            model="claude-opus-4-8", max_tokens=1024,
            tools=TOOLS, messages=messages,
        )

        if resp.stop_reason == "end_turn":        # model is finished
            return next(b.text for b in resp.content if b.type=="text")

        # else: model asked for tool(s). Append its turn verbatim.
        messages.append({"role":"assistant", "content": resp.content})

        results = []
        for block in resp.content:
            if block.type == "tool_use":
                fn = DISPATCH[block.name]
                out = fn(**block.input)             # run the real function
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,          # MUST match
                    "content": str(out),
                })
        # ALL results go back in ONE user message
        messages.append({"role":"user", "content": results})

    return "Stopped: hit step limit."

print(run_agent("What's the weather in Tokyo?"))
It's currently 18°C and cloudy in Tokyo.
▶ How this works

This is the agent — the while/for loop that makes the model act. Read it as a cycle: call the model → is it done? → if not, run the tools it asked for → feed the results back → repeat. Everything else is bookkeeping to keep that cycle correct.

  1. TOOLS is the list of tool descriptions you pass to the model. DISPATCH maps each tool name to the real function to run — so when the model asks for "get_weather", you know which Python function to call.
  2. MAX_STEPS = 6 is a hard cap on how many times the loop can go around. This is a safety rail: without it, an agent can loop forever and burn money. for step in range(MAX_STEPS) guarantees it stops.
  3. Inside the loop, client.messages.create(...) sends the whole conversation (messages) and the tools. Then if resp.stop_reason == "end_turn" checks the model's reply: if it's finished, we grab its text and return — that's the exit.
  4. Otherwise the model asked for tools. First we append the model's turn verbatim (resp.content) to history. Then the inner for block in resp.content loop finds each tool_use block, looks up the function with DISPATCH[block.name], and runs it with fn(**block.input).
  5. Each result is packaged as a tool_result carrying the same tool_use_id the model sent (that's how it pairs your answer to its request). All results go back in one user message, and the loop repeats with the model now able to see them.

What the output means: The model asks for get_weather("Tokyo"), your loop runs it, feeds "18°C and cloudy in Tokyo" back, and on the next pass the model finishes with It's currently 18°C and cloudy in Tokyo.

Try this: Add print(step, resp.stop_reason) at the top of the loop and re-run. You'll see it go around twice: once returning tool_use, then once returning end_turn. That's the loop working, made visible.

Three things people get wrong here
  • Append the assistant turn verbatim (resp.content) before adding results — the tool_use block must be in history or the next call errors.
  • Every tool_result needs the matching tool_use_id — that's how the model pairs request to result.
  • All results in ONE user message — splitting them across messages trains the model out of parallel calls.

Lab 4.3 · Multiple & parallel tools advanced

Here's the moment the loop you just wrote earns its design. You'd expect that giving the agent a second tool means more code — a bigger loop, extra branches, special handling for "what if it wants both?" It doesn't. You add a tool definition and one line to the dispatch map, and the loop from Lab 4.2 handles it with zero changes. The first time you watch that happen it feels like cheating. It isn't — it's the payoff of having written the loop generically instead of hard-coding one tool.

The reason is worth internalizing, because it's why good agent code stays small as it grows. Your inner loop doesn't say "if the model wants weather, do X; if it wants search, do Y." It says "for every tool the model asked for, look up the function by name and run it." That single generic step — for block in resp.content, dispatch by block.name — is indifferent to how many tools exist or which ones the model picks. Register ten tools and the loop is unchanged; the model simply has ten names to choose from. Think of the dispatch map as a switchboard: the loop is the operator who connects any incoming request to the right line, and adding a new line means adding one entry to the directory, not rebuilding the switchboard.

Two behaviors fall out of this for free, and they're the point of the "advanced" label. The model can chain tools — call get_weather, read the result, then decide it also needs to recommend clothing — which is just multiple trips around the loop, each informed by the last. And it can call tools in parallel — ask for weather in Oslo and search the travel policy in a single turn — because a model reply can contain several tool_use blocks at once. Your loop already runs every block in that turn and returns every result together, so parallel calls "just work." The one mistake that quietly breaks this is splitting those parallel results across separate messages instead of batching them into one — do that and you train the model out of asking for parallel calls at all, because from its point of view the calls didn't come back cleanly. Keep all results for a turn in a single user message, exactly as Lab 4.2 does, and the composition stays effortless.

the loop is a switchboard — it dispatches any tool by name, so adding tools changes nothing Model reply one turn, many blocks tool_use: weather tool_use: search DISPATCH name → function the switchboard get_weather(...) search_docs(...) add a tool = one new directory entry · the loop code is untouched Dispatch by name, not by branch. The loop runs every tool_use block in a reply and looks each one up in the dispatch map — so a single turn can fan out to parallel tools, and adding a tool means one map entry, not new loop code.
🗺️ How to read this diagram

Read left to right: the model's reply on the left, the dispatch map in the middle, the real functions on the right.

  • The purple box is one model reply holding two tool_use blocks at once — that is a parallel call: the model asked for weather and search in a single turn.
  • The white DISPATCH box is the switchboard: it maps each tool name to the real Python function. The loop's inner step is generic — "for every block, look up block.name and run it" — so it never has a branch per tool.
  • The arrows fanning out to the green boxes show every requested block being run, and the results are batched back in one message. Because the loop dispatches by name, registering a third or tenth tool is just one more entry in the directory — the footer line — with no change to the loop itself.

In short: the loop dispatches by name and runs every block in the turn, so multiple and parallel tools "just work" and growth costs one map entry, not new control flow.

Lab 4.3

Continues tools.py from earlier in this lesson — run the previous block(s) first.

A second tool, no loop change — in Claude or OpenAI

Same flat difference as Lab 4.1 — Claude uses input_schema, OpenAI uses "type":"function" + parameters. The TOOLS/DISPATCH wiring is byte-for-byte identical either way, and the loop code is untouched. Toggle the tab to switch SDK.

tools.py (add)SEARCH_TOOL = {
    "name": "search_docs",
    "description": "Search the internal knowledge base. Use when the user "
                   "asks about company policy, products, or procedures.",
    "input_schema": {"type":"object",
        "properties":{"query":{"type":"string"}}, "required":["query"]},
}
def search_docs(query):
    return retrieve_from_ch3(query)      # reuse your RAG retriever!
agent.py (update)TOOLS = [WEATHER_TOOL, SEARCH_TOOL]
DISPATCH = {"get_weather": get_weather, "search_docs": search_docs}
▶ How this works

Adding a second tool needs no change to the loop — that's the payoff of Lab 4.2. You define the new tool exactly like the first, then register it, and the same loop will now call either one (or both) as the model sees fit.

  1. SEARCH_TOOL is a second tool dictionary, same shape as WEATHER_TOOL: a name, a when-to-use description, and an input_schema (here it takes a query string).
  2. def search_docs(query) is its real function. It just calls retrieve_from_ch3(query) — the RAG retriever you built in Chapter 3. You've turned retrieval into a tool the model can choose to use.
  3. In agent.py you add both entries: TOOLS = [WEATHER_TOOL, SEARCH_TOOL] and a matching DISPATCH line. That's the only wiring — the loop code is untouched.

What the output means: Now the model decides on its own whether a question needs the weather tool, the search tool, both, or neither — and the same loop runs whatever it picks.

Try this: Ask a question that needs both ("What's the weather in Oslo, and what's our travel policy?") and watch the model request two tools. Because your loop runs every requested tool in one pass, it just works.

Agentic RAGNotice you just turned retrieval into a tool. Now the model decides when to search and can issue follow-up searches — this is "agentic RAG," strictly more powerful than the fixed pipeline in Chapter 3 (and more expensive). Same building blocks, composed differently.

Lab 4.4 · Safety — gates, caps, validation advanced

A demo agent and a production agent run almost identical code — the difference is entirely in this section, and it's the difference between a party trick and something you'd trust near real systems. A chatbot that gives wrong weather is embarrassing. An agent that can send email, delete records, or issue refunds and gets one call wrong is a very different kind of Monday. The instant your tools can change the world rather than just read it, "the model usually gets it right" stops being good enough, because the failures you care about are the rare, expensive, irreversible ones.

The mental shift is from reversible to irreversible. Reading data is reversible — a wrong read costs you a wasted call and nothing more, so let the agent do it freely. But you cannot un-send an email, un-delete a record, or un-spend money. For that class of action the right instinct is the one every serious operations team already lives by: a human confirms before the irreversible thing happens. That's the human-in-the-loop gate — a checkpoint that pauses the agent and asks a person "are you sure?" before running anything on the dangerous list, exactly like a bank app making you re-confirm a large transfer.

The elegant part of the code below is how a denial is handled. When the human says no, the code doesn't raise an exception or crash the run — it feeds the string "DENIED by human. Suggest an alternative." back into the loop as the tool's result. That matters more than it looks: because the denial arrives as data the model can read, the agent stays in its normal loop and simply adapts — it proposes a different course instead of exploding or silently stalling. Returning refusals as data, not exceptions, is the design that keeps a gated agent graceful. The mistake to avoid is over-gating: dump every tool into the destructive set and you'll be tapping y all day for read-only lookups, which trains you to approve on autopilot — and an approval gate you approve reflexively is no gate at all. Put only genuinely harmful, irreversible tools behind the human, and keep the four rails in the checklist below in mind: a step cap, human approval for irreversible actions, validated inputs, and least-privilege credentials — because an agent can do anything its keys allow.

reversible runs freely · irreversible waits for a human — denial flows back as data Tool requested by the model destructive? in DESTRUCTIVE set run it read-only · safe no human gate Approve? [y/N] yes y → run N → "DENIED" as tool_result Gate only the irreversible. Read-only tools run straight through; a tool in the DESTRUCTIVE set pauses for a human y/N — approve and it runs, deny and the string "DENIED…" is fed back as the tool result so the agent adapts instead of crashing.
🗺️ How to read this diagram

Follow the arrows left to right from the requested tool through the one decision that matters: is this tool destructive?

  • The "destructive?" diamond-style box is the only check: membership in the DESTRUCTIVE set. That single test is the whole gate.
  • The top green path ("no") is a read-only tool — it runs straight through and is safe to run in parallel. No human, no friction; over-gating these is the mistake to avoid.
  • The red "human gate" path ("yes") pauses for an explicit y/N. On y the tool runs (green); on N the code does not raise — it returns the string "DENIED…" as the tool result (amber), which flows back into the loop as data the model reads and adapts to.

In short: the split is reversible vs irreversible — run reads freely, gate mutations behind a human, and return refusals as tool results so a denied agent changes course rather than crashing.

Lab 4.4
  1. Step cap — already in Lab 4.2. Never write an unbounded agent loop; it can spin forever and burn money.
  2. Human-in-the-loop gate for destructive tools.

    Continues agent.py from earlier in this lesson — run the previous block(s) first.

    The human-approval gate — in Claude or OpenAI

    The gate logic is the same — the only difference is where the name and arguments come from: Claude's block.name/block.input (a dict) vs OpenAI's call.name/json.loads(call.arguments) (a JSON string). Denial is fed back as the tool result in both. Toggle the tab to switch SDK.

    agent.py (in the tool loop)DESTRUCTIVE = {"send_email", "delete_record", "issue_refund"}
    
    if block.name in DESTRUCTIVE:
        print(f"⚠ Agent wants: {block.name}({block.input})")
        if input("Approve? [y/N] ") != "y":
            out = "DENIED by human. Suggest an alternative."
        else:
            out = DISPATCH[block.name](**block.input)
  3. Validate inputs inside the tool — the model's arguments are untrusted. Check types, ranges, permissions before acting.
  4. Sandbox code/shell execution. If a tool runs shell or code, run it in an isolated, network-restricted container with resource limits — never on your host with your credentials.
▶ How this works

Some tools do things you can't undo — send an email, delete a record, refund money. This snippet drops a human approval gate in front of those tools, right inside the loop, so a person must say yes before anything irreversible happens.

  1. DESTRUCTIVE = {...} is a set of the tool names you consider dangerous. Membership in this set is the whole trigger for the gate.
  2. if block.name in DESTRUCTIVE: — before running a tool, check if it's on the dangerous list. If it is, we pause and ask the human instead of running it immediately.
  3. input("Approve? [y/N] ") stops and waits for you to type. If you don't type exactly y, we don't run the tool — instead out becomes a message saying it was denied. Only on y do we actually call DISPATCH[block.name](**block.input).
  4. Crucially, the denial is sent back as the tool result. The model reads "DENIED by human. Suggest an alternative." and adapts — it doesn't crash or silently fail; it changes course.

What the output means: For a safe tool, nothing changes. For a destructive one, the run pauses for your y/N; deny it and the agent politely proposes something else.

Try this: This is the single most important production safety rail. Ask: which of my tools can cause real-world harm? Put exactly those in DESTRUCTIVE — and nothing read-only, or you'll be approving prompts all day.

The agent safety checklist
  • ✅ Hard step cap on the loop
  • ✅ Human approval for irreversible actions
  • ✅ Tool inputs validated before execution
  • ✅ Least-privilege credentials (the agent can do whatever its keys allow)
  • ✅ Untrusted tool output can carry injected instructions — don't let it silently redirect the agent (Ch 6)

Lab 4.5 · Memory across turns advanced

Talking to the Lab 4.2 agent twice is like talking to someone with no short-term memory. You say "I'm planning a trip to Oslo," it answers helpfully — and then you ask "what's the weather there?" and it has no idea where "there" is, because the moment the previous run_agent call returned, its entire messages list evaporated. Every call started from a blank conversation. That's fine for a one-shot question, and useless for anything that feels like an actual assistant.

The fix is almost anticlimactic, and that's the lesson: the model has no memory of its own — the conversation history is the memory. A language model is stateless; it remembers nothing between calls. What makes it seem to remember is that you resend the whole prior conversation every time. So all "giving the agent memory" means is: don't throw the messages list away between turns. Keep appending to the same list, resend it each call, and the model can "recall" Oslo because Oslo is literally still sitting there in the transcript you handed it. The lab does this by wrapping the loop in a small class whose self.messages lives on the object and survives across .chat() calls. The persistence of that one list is the whole trick — there is no hidden memory store.

That framing immediately tells you the catch. If memory is "resend everything," then a long conversation means resending an ever-growing transcript — and you pay, in tokens and latency, for every past turn on every new call. Eventually the history outgrows the model's context window and the call fails outright. The mistake here is the opposite of forgetting: hoarding. Beginners either build no memory at all, or they keep every token forever and are baffled when a long chat gets slow, expensive, and then breaks. The three levers in the note below — context editing (prune stale tool results), compaction (summarize old history into a short synopsis), and persistent memory (write durable facts to a file or database the agent reads next session) — are how you keep memory bounded. Reach for them when sessions genuinely run long; adding them to a short-lived agent is premature complexity you'll regret maintaining.

the model is stateless — the messages list IS the memory, resent in full each call user: "Oslo" assistant user: "there?" self.messages (persists) Model stateless — no memory Reply "weather in Oslo" resend ALL append reply → list grows → eventually bound it (edit · compact · persist) History is the memory. The model remembers nothing between calls — it "recalls" Oslo only because the whole messages list is resent every turn; that same fact is why the list grows unbounded and must eventually be trimmed, summarized, or persisted.
🗺️ How to read this diagram

Read the growing stack on the left as the conversation, then follow it into the model and back around.

  • The stacked boxes on the left are self.messages — every turn (user, then assistant, then the next user) is appended to this one list that lives on the Agent object and survives between .chat() calls.
  • The "resend ALL" arrow into the purple Model box is the trick: because the model is stateless — it holds no memory of its own — you hand it the entire history every call. That's why it can answer "weather there?" with "Oslo": Oslo is still physically in the transcript.
  • The dashed loop-back arrow appends each reply to the list — so the list only grows. The footer names the fix: when a session runs long, bound it with context editing, compaction, or persistent memory.

In short: memory = don't throw the messages list away and resend it every call; the catch is that it grows without limit, so long sessions need trimming, summarizing, or a durable store.

Lab 4.5

Uses the client / objects set up earlier in this lesson, and needs an API key set. Run the earlier blocks first.

Memory across turns — in Claude or OpenAI

Memory is just "don't throw the list away" — identical idea in both SDKs. Claude keeps a messages list and branches on stop_reason; OpenAI keeps an input list, appends resp.output verbatim, and loops while any function_call remains. Toggle the tab to switch SDK.

stateful_agent.pyclass Agent:
    def __init__(self):
        self.messages = []              # persists across .chat() calls

    def chat(self, user_input):
        self.messages.append({"role":"user","content":user_input})
        for _ in range(MAX_STEPS):
            resp = client.messages.create(model="claude-opus-4-8",
                     max_tokens=1024, tools=TOOLS, messages=self.messages)
            self.messages.append({"role":"assistant","content":resp.content})
            if resp.stop_reason == "end_turn":
                return next(b.text for b in resp.content if b.type=="text")
            results = self._run_tools(resp)
            self.messages.append({"role":"user","content":results})

a = Agent()
print(a.chat("I'm planning a trip to Oslo."))
print(a.chat("What's the weather there?"))   # remembers "Oslo"
▶ How this works

The loop in Lab 4.2 forgets everything the moment it returns. To hold a real conversation, the agent must remember previous turns. This wraps the same loop in a small class whose messages list survives between calls — that persistence is the memory.

  1. class Agent bundles the conversation with the loop. __init__ creates self.messages = [] once. Because it lives on the object, it is not reset between calls — each new message is appended to the same growing list.
  2. chat(self, user_input) holds the familiar loop: add the user's message, then for _ in range(MAX_STEPS) call the model, append its reply, and either return on end_turn or run tools and loop again — exactly Lab 4.2, but reading and writing self.messages.
  3. The two a.chat(...) calls at the bottom prove the memory works: the first mentions Oslo; the second only asks "What's the weather there?" and the agent still knows "there" means Oslo — because that first turn is still in self.messages.

What the output means: The second call correctly answers about Oslo even though you never repeat the city — the history carried it forward.

Try this: Start a second Agent() and ask it the "weather there?" question directly. It won't know where — a fresh object has an empty messages. That contrast is exactly what memory buys you.

Managing long sessionsAs messages grows it costs more and eventually exceeds the context window. Three levers: context editing (prune stale tool results), compaction (summarize old history), and persistent memory (write facts to a file/DB the agent can read next session). Reach for these when sessions run long — don't prematurely add them.

Designing a good tool surface expert

The tools you give an agent shape its behavior more than any prompt you write. I've watched two teams build the "same" agent get wildly different results, and every time the gap traced back to the tool surface — the set of tools, how broad each one is, how they're described — not the model or the system prompt. The prompt is advice; the tools are the world the agent actually lives in. Design that world carelessly and no amount of prompt-tuning rescues it.

The sharpest design tension is broad versus narrow. A broad tool — say, "run any bash command" — gives the agent enormous reach in one definition: it can do almost anything a shell can. But that reach is opaque. You can't easily gate it (which commands are dangerous?), can't validate its inputs (any string is "valid"), can't render its intent to a human, and can't reason about whether two calls are safe to run in parallel. A narrow tool — send_email(to, subject, body) — gives up reach for control: your harness can inspect exactly what's being sent, require approval, validate the address, and log it cleanly. The everyday analogy is a Swiss Army knife versus a labeled toolbox: the knife does everything and you can't tell what someone's about to do with it; the toolbox makes each action explicit. Start broad to explore what the agent needs, then promote to narrow the moment you need control over a specific action.

The mistake that creeps in as a project matures is tool sprawl. Every new capability feels like it deserves its own tool, and before long the agent is choosing among forty of them — at which point the model gets worse, not better, because the description of each tool is context the model must read and weigh on every single call, and near-duplicate tools blur the "when to use it" signal. Keep the set focused and each description crisp; for genuinely large tool libraries, load schemas on demand (tool search) rather than pasting all forty into every request. And carry the read-only-versus-mutating distinction from Lab 4.4 straight into the design: mark read-only tools parallel-safe so they can run concurrently, and gate the mutating ones. The table is your design checklist — walk it for every tool before you ship it.

the same reach, sliced two ways — you trade reach for control as you go narrow Broad run_bash(cmd) does anything — opaque harness can't: ✗ gate ✗ validate ✗ render ✗ parallelize Narrow send_email(to, subj, body) delete_record(id) read_metrics(name) can gate · validate · render · parallel promote for control Reach vs control. One broad run_bash can do almost anything but the harness can't gate, validate, render, or parallelize it; splitting it into narrow, typed tools gives up raw reach so your code can inspect, approve, and run each action safely.
🗺️ How to read this diagram

Compare the two columns side by side: the single red box on the left is the broad design, the stack of small boxes on the right is the same capability sliced into narrow tools.

  • The red box (run_bash) is broad: enormous reach in one definition, but everything underneath is a list of things your harness cannot do — it can't tell which command is dangerous, can't validate an arbitrary string, can't show a human what's about to happen, can't decide what's safe to parallelize.
  • The green and blue boxes on the right are narrow, typed tools. Each one is explicit, so the harness can gate the mutating ones, validate arguments, render intent, and mark read-only ones parallel-safe (the blue read_metrics).
  • The blue arrow "promote for control" is the workflow: start broad to explore what the agent needs, then convert a capability to a narrow tool the moment you need control over that specific action.

In short: broad buys reach and costs control; narrow buys control and costs reach. Explore broad, then promote to narrow exactly where a human needs to gate or validate.

QuestionGuidance
Broad tool (bash) or narrow (send_email)?Broad = reach but opaque; narrow = the harness can gate, validate, render, parallelize. Promote to narrow when you need control.
How many tools?Keep the set focused. Too many confuse the model. For huge sets, load schemas on demand (tool search).
What goes in the description?When to use it, not just what it does. Include argument examples for complex schemas.
Read-only vs. mutating?Mark read-only tools parallel-safe; gate mutating/irreversible ones.

Common pitfalls expert

Almost every broken agent I've debugged failed for one of a small handful of reasons — the same ones, over and over. That's the good news hiding in this table: agent bugs are not a bottomless pit of novel mysteries. They cluster. Learn to recognize the half-dozen classic failure signatures below and you'll fix in minutes what otherwise costs an afternoon of confused staring at API errors.

It helps to sort them into two buckets, because they fail in very different ways. The first bucket is protocol mistakes — you broke the shape of the conversation the API expects. Forgetting to append the assistant turn before the tool results, mismatching a tool_use_id, or splitting parallel results across messages all land here, and they announce themselves loudly with a 400-style error on the next call. Annoying, but honest: the API tells you immediately that something is malformed, and the fix is mechanical. The analogy is a form rejected for a missing field — irritating, instantly diagnosable.

The second bucket is quieter and more dangerous: judgment mistakes that don't error at all. An agent with no step cap doesn't crash — it runs beautifully, in a loop, spending money until you notice the bill. An agent that trusts its tool arguments blindly doesn't error — it cheerfully deletes the wrong record. And the most strategic mistake of all, building an agent for a task that should have been a plain workflow, produces something that works in the demo and is needlessly slow, costly, and flaky forever after. These don't announce themselves; you have to design against them up front. The rule of thumb that catches most of them: cap every loop, validate every tool input, gate every irreversible action, and before you build the agent at all, ask honestly whether a script would do the job. The table pairs each pitfall with its one-line fix so you can use it as a pre-flight checklist.

agent bugs cluster into two kinds — one fails loud, one fails silent Protocol mistakes wrong shape of conversation missing assistant turn mismatched tool_use_id split parallel results → loud 400 on next call · mechanical fix Judgment mistakes no error at all no step cap → burns money trusting args → wrong delete agent for a scriptable task → silent · design against it up front Loud vs silent. Protocol mistakes break the API's expected message shape and announce themselves with an immediate 400 you fix mechanically; judgment mistakes never error — they quietly spend money, delete the wrong thing, or over-engineer a script — so you must design against them before you ship.
🗺️ How to read this diagram

Two panels, one per bucket. The amber panel on the left is the noisy kind of bug; the red panel on the right is the quiet, dangerous kind. The small boxes inside each are the specific failures that live there.

  • The amber "protocol mistakes" are all ways you broke the shape the API expects — forgetting the assistant turn, a mismatched tool_use_id, splitting parallel results. Their footer line is the tell: they surface as a loud 400 on the very next call, and the fix is mechanical.
  • The red "judgment mistakes" produce no error — an uncapped loop just burns money, blindly trusted arguments delete the wrong record, and an agent built for a scriptable task quietly stays slow and flaky. Nothing tells you; you only find out later.
  • The color choice is the payoff: amber = annoying-but-safe (it stops you), red = silent-but-costly (it doesn't). That's why the red bucket must be designed against up front, not debugged after.

In short: loud protocol bugs get caught by the API; silent judgment bugs don't — cap every loop, validate every input, gate every irreversible action, and ask whether a script would do before you build.

PitfallFix
No step cap → infinite loop, runaway costHard MAX_STEPS on every loop
Forgot to append the assistant turnAppend resp.content before tool results
Mismatched / missing tool_use_idCopy block.id into each tool_result
Split parallel results across messagesAll tool_result blocks in one user message
Trusting tool arguments blindlyValidate inside the tool; gate destructive ones
Building an agent for a scriptable taskUse a workflow (Ch 2–3) — cheaper and more reliable

Exercises expert

Exercise 4.1 — Calculator agent

Context: The clearest way to see the loop work is to make the model chain tools: a calculator that must add, then multiply, forces two passes around the loop.

Your task: Add an add and a multiply tool, ask "What is (12 + 8) × 5?", and print each step so you can watch the agent chain two tool calls.

Requirements:

  • Two tools registered in TOOLS and DISPATCH
  • The agent chains them: add first, then multiply on the result
  • Each loop iteration is printed so the chaining is visible
  • The final answer (100) comes back as an end_turn text reply

💡 Hint: Print step and resp.stop_reason at the top of the loop — you'll see tool_use passes before the final end_turn.

Exercise 4.2 — Wire in your RAG retriever

Context: Turning your Chapter 3 retriever into a tool is the leap from a fixed RAG pipeline to agentic RAG — now the model decides when to search and can follow up.

Your task: Make search_docs actually call the Chapter 3 hybrid retriever, then ask a question answerable only from your docs/ and confirm the agent chooses to search.

Requirements:

  • search_docs calls the real Ch 3 retriever, not a stub
  • It is registered in both TOOLS and DISPATCH
  • A docs-only question causes the model to issue a search_docs call
  • The final answer is grounded in the retrieved chunk

💡 Hint: The loop needs no changes — adding the tool and dispatch entry is the whole wiring, exactly as in Lab 4.3.

Exercise 4.3 — Add a gate

Context: Some tools do things you can't undo. A human-approval gate in front of them is the single most important production safety rail on an agent.

Your task: Add a delete_file tool and gate it behind human approval; confirm the agent pauses for your y/N before the tool runs, and that denying it makes the agent adapt.

Requirements:

  • delete_file is treated as destructive and gated before execution
  • The run pauses and waits for an explicit y to proceed
  • On denial, a tool_result explaining the refusal is returned to the model
  • The model reads the denial and changes course instead of crashing or silently failing

💡 Hint: Return the denial as the tool result content ("DENIED by human — suggest an alternative") so the model reacts to it as data.

Show hint

Return a tool_result whose content explains the denial ("DENIED by human — suggest an alternative"). The model reads that and changes course, rather than silently failing.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Define a tool schema the model can callBeginner

Context: Before a model can act, it needs to know a tool exists — and the only thing it ever sees is your description and JSON schema. Getting that dict shape right is the first, non-negotiable step of every agent.

Your task: Write the tool dictionary for an add(a, b) tool — a name, a when-to-use description, and a JSON-schema input_schema with two required number inputs — then assert the shape.

Requirements:

  • The dict has exactly three top-level keys: name, description, input_schema
  • input_schema is type: object with a and b both typed number
  • required lists both inputs: ["a", "b"]
  • The description says when to reach for the tool, not just what it does
  • A runnable assert proves the key set and the required list

💡 Hint: Mirror the lesson's WEATHER_TOOL shape exactly; the schema is a plain nested dict, and set(TOOL) is enough to check the top-level keys.

Show solution

Follow the lesson's tool-dict shape. Runnable structural check:

ADD_TOOL = {
    "name": "add",
    "description": "Add two numbers. Call this whenever the user needs a sum.",
    "input_schema": {
        "type": "object",
        "properties": {
            "a": {"type": "number"},
            "b": {"type": "number"},
        },
        "required": ["a", "b"],
    },
}
assert set(ADD_TOOL) == {"name", "description", "input_schema"}
assert ADD_TOOL["input_schema"]["required"] == ["a", "b"]
print("valid tool:", ADD_TOOL["name"])
Exercise 2 · Dispatch a tool call by nameIntermediate

Context: When the model asks for a tool it returns a tool_use block, not a function call — your code has to translate that name into a real Python function and hand the answer back with the id the model can pair to its request.

Your task: Combine a DISPATCH map (tool name → real function) with fn(**block.input): simulate one tool_use block, run its function, and build the tool_result carrying the matching tool_use_id.

Requirements:

  • DISPATCH maps the tool name to the actual callable
  • Call the function by unpacking the block's input: fn(**block.input)
  • The result is a dict with type: "tool_result" and a content string
  • The tool_use_id on the result equals the id on the request block — assert it
  • Runs offline with a stand-in object for the content block (no API call)

💡 Hint: A tiny class or dict with .id, .name, and .input is enough to stand in for a real content block.

Show solution

The tool_use_id MUST be copied onto the result so the model pairs request to answer. Runnable simulation:

def get_weather(city, unit="celsius"):
    return f"18°{'C' if unit=='celsius' else 'F'} and cloudy in {city}"

DISPATCH = {"get_weather": get_weather}

class ToolUse:                       # stands in for a tool_use content block
    type = "tool_use"
    id = "toolu_01"
    name = "get_weather"
    input = {"city": "Tokyo"}

block = ToolUse()
fn = DISPATCH[block.name]
out = fn(**block.input)
result = {"type": "tool_result", "tool_use_id": block.id, "content": str(out)}
print(result)
assert result["tool_use_id"] == block.id      # must match the request
Exercise 3 · The full loop with a hard step capAdvanced

Context: The agentic loop is the whole chapter: call the model, check stop_reason, run any requested tools, feed results back, repeat. The one rail that must never be optional is the step cap — without it an agent can spin forever and burn money.

Your task: Write the Lab 4.2 loop against a fake model so it runs offline: append the assistant turn verbatim, batch all tool results in ONE user message, exit on end_turn, and stop at MAX_STEPS. Prove the cap terminates even a model that never says done.

Requirements:

  • Loop is bounded by for step in range(MAX_STEPS)
  • On stop_reason == "end_turn" it returns the text block and exits
  • The assistant turn (resp.content) is appended to history before the results
  • Every tool_use block produces a tool_result with its tool_use_id, all in one user message
  • A runaway model that always returns tool_use still stops at the cap with a fixed message
  • No network: the model is a plain Python function returning dicts

💡 Hint: Make the fake model a callable that returns a tool_use dict first and an end_turn dict second; a second model that always returns tool_use demonstrates the cap.

Show solution

The fake model returns a tool_use then an end_turn; a second test model loops forever to show the cap saves you. Runnable:

MAX_STEPS = 6

def run_agent(user_input, model):
    messages = [{"role":"user", "content": user_input}]
    for step in range(MAX_STEPS):
        resp = model(messages)
        if resp["stop_reason"] == "end_turn":
            return next(b["text"] for b in resp["content"] if b["type"]=="text")
        messages.append({"role":"assistant", "content": resp["content"]})  # verbatim
        results = []
        for b in resp["content"]:
            if b["type"] == "tool_use":
                results.append({"type":"tool_result", "tool_use_id": b["id"],
                                "content": "42"})
        messages.append({"role":"user", "content": results})   # ALL results, ONE message
    return "Stopped: hit step limit."

calls = {"n": 0}
def good_model(msgs):
    calls["n"] += 1
    if calls["n"] == 1:
        return {"stop_reason":"tool_use",
                "content":[{"type":"tool_use","id":"t1","name":"add","input":{}}]}
    return {"stop_reason":"end_turn", "content":[{"type":"text","text":"The answer is 42."}]}

def runaway_model(msgs):             # never says done
    return {"stop_reason":"tool_use",
            "content":[{"type":"tool_use","id":"t","name":"add","input":{}}]}

print(run_agent("add stuff", good_model))
print(run_agent("spin forever", runaway_model))   # cap stops it
Exercise 4 · Run parallel tool calls, keep results alignedExpert

Context: A model can ask for several tools in a single turn. The correctness trap everyone hits: you must run every requested tool and return ALL results in ONE user message, each tagged with its own id — miss one and the next API call errors.

Your task: Write the inner loop that iterates all tool_use blocks, dispatches each, collects the tool_results, and verifies every request id has exactly one matching result id.

Requirements:

  • Iterate a content list holding two or more tool_use blocks
  • Dispatch each block with fn(**block.input) via a name→function map
  • Each result carries its own tool_use_id copied from the request
  • Assert the set of request ids equals the set of result ids
  • All results are placed in a single user message (one content list)

💡 Hint: Build the request-id set and result-id set separately and compare them with == — set equality is the cleanest one-to-one check.

Show solution

Iterate all tool_use blocks, dispatch each, collect results, then check the id sets match. Runnable:

def get_weather(city, **_): return f"18C in {city}"
def search_docs(query, **_): return f"results for {query}"
DISPATCH = {"get_weather": get_weather, "search_docs": search_docs}

content = [   # model asked for TWO tools at once
    {"type":"tool_use", "id":"u1", "name":"get_weather", "input":{"city":"Oslo"}},
    {"type":"tool_use", "id":"u2", "name":"search_docs", "input":{"query":"travel policy"}},
]

results = []
for b in content:
    if b["type"] == "tool_use":
        out = DISPATCH[b["name"]](**b["input"])
        results.append({"type":"tool_result", "tool_use_id": b["id"], "content": str(out)})

req_ids = {b["id"] for b in content if b["type"]=="tool_use"}
res_ids = {r["tool_use_id"] for r in results}
assert req_ids == res_ids, "every request needs exactly one matching result"
print("matched ids:", sorted(res_ids))
one_message = {"role":"user", "content": results}   # all in ONE message
print("results in one message:", len(one_message["content"]))
Exercise 5 · Human-in-the-loop gate for destructive toolsProfessional

Context: An agent takes actions, and some are irreversible — sending email, deleting records, issuing refunds. The production rail is a human approval gate in front of exactly those tools, and a denial must flow back as data so the model adapts rather than crashes.

Your task: Implement execute_tool(name, args, dispatch, approver) that runs read-only tools directly but, for any tool in a DESTRUCTIVE set, requires approval — returning a denial tool_result string when refused. Make the approver injectable so it is testable without stdin.

Requirements:

  • Membership in a DESTRUCTIVE set is the sole trigger for the gate
  • A denied destructive tool returns the denial as a string, never raises
  • The denial text tells the model it was denied and to suggest an alternative
  • The approver is a parameter (function), not a hard-coded input() call
  • Demonstrated on three paths: a read-only tool, a denied destructive tool, an approved destructive tool

💡 Hint: Passing the approver in lets your tests use lambda name, args: True and lambda name, args: False instead of typing at a prompt.

Show solution

The lesson gates on set membership and returns the denial as data. Making the approver a parameter is what makes it unit-testable. Runnable with both an approve and a deny path:

DESTRUCTIVE = {"send_email", "delete_record", "issue_refund"}

def execute_tool(name, args, dispatch, approver):
    if name in DESTRUCTIVE:
        if not approver(name, args):
            return "DENIED by human. Suggest an alternative."   # data, not an exception
    return dispatch[name](**args)

dispatch = {
    "get_weather":   lambda city, **_: f"18C in {city}",
    "delete_record": lambda id, **_: f"deleted {id}",
}

auto_deny    = lambda name, args: False
auto_approve = lambda name, args: True

print(execute_tool("get_weather", {"city":"Oslo"}, dispatch, auto_deny))   # read-only: runs
print(execute_tool("delete_record", {"id":"42"}, dispatch, auto_deny))     # gated: denied
print(execute_tool("delete_record", {"id":"42"}, dispatch, auto_approve))  # gated: approved

# In production the approver reads a real y/N; the denial string flows back as the
# tool_result so the model changes course instead of crashing.

Safety-checklist alignment: hard step cap (other rung), approval for irreversible actions (here), validated inputs, least-privilege keys, and treating tool output as untrusted. Put only genuinely harmful tools in DESTRUCTIVE or you'll approve prompts all day.

Exercise 6 · Decide agent-vs-workflow, then design a safe DevOps agent sliceIndustry scenario

Context: Your team wants an agent that can run kubectl get (read-only) and terraform apply (irreversible) to handle on-call toil. The whole design hinges on whether it should even be an agent, and on gating every mutating action.

Your task: Apply the lesson's four-question test (Complexity, Value, Viability, Cost of error) to justify the agent, then design the tool surface and provide a runnable policy(tool) classifier that splits read-only from mutating and default-denies unknown tools.

Requirements:

  • The four checks are worked through explicitly to reach the agent-vs-workflow verdict
  • Read-only tools are auto-run and marked parallel-safe
  • Mutating tools require approval and are serialized (not parallel-safe)
  • Unknown tools hit a default-deny branch — never silently allowed
  • Runnable asserts confirm the policy for a read, a mutation, and an unknown tool
  • Tradeoffs note the step cap, least-privilege credentials, and treating tool output as untrusted

💡 Hint: Return a small policy dict (run, parallel_safe) per tool; the default-deny is just the fall-through case after the two known sets — this is the capstone's safety model in miniature.

Show solution

Decision. Run the four checks: Complexity — triage is multi-step and hard to fully script (yes); Value — on-call toil is expensive, autonomy pays (yes); Viability — models are good at reading cluster/plan output (yes); Cost of error — reads are safe, but apply is irreversible, so the whole design hinges on gating that one class. Because the reversible/irreversible split is clean, an agent is justified if every mutating action is gated; a pure workflow would be too rigid for open-ended triage.

Design. Split tools into read-only (parallel-safe, run freely) and mutating (human gate, one at a time), mirroring the lesson's tool-surface table. The classifier below is the policy that drives the gate — runnable:

READ_ONLY  = {"kubectl_get", "terraform_plan", "log_search"}
MUTATING   = {"terraform_apply", "kubectl_delete", "scale_deployment"}

def policy(tool):
    if tool in READ_ONLY:
        return {"run": "auto", "parallel_safe": True}
    if tool in MUTATING:
        return {"run": "require_approval", "parallel_safe": False}
    return {"run": "deny_unknown", "parallel_safe": False}   # default-deny

for t in ["kubectl_get", "terraform_apply", "rm_rf_slash"]:
    print(f"{t:16} -> {policy(t)}")

assert policy("terraform_apply")["run"] == "require_approval"
assert policy("kubectl_get")["run"] == "auto"
assert policy("anything_else")["run"] == "deny_unknown"   # unknown tools are refused

Tradeoffs. Default-deny on unknown tools means the agent can't be tricked into a tool you never blessed (defends against injected instructions from tool output). Reads run in parallel for speed; mutations are serialized behind a human so two applies can't race. Pair this with a hard step cap, least-privilege cloud credentials (the agent can do whatever its keys allow), and input validation inside each tool. This read-only/gated split is exactly the capstone AI DevOps Engineer's safety model.

✓ Checkpoint — you can move on when you can…

  • Explain the agentic loop and its two exit conditions from memory.
  • Define a tool with a schema and a "when to use it" description.
  • Write the loop correctly: append assistant turn, match tool_use_id, batch results.
  • Add a step cap and a human-in-the-loop gate for destructive actions.
  • Persist memory across turns and name the three long-session levers.
  • Decide when a task should be an agent vs. a plain workflow.
🏗️ Toward the capstoneThis chapter is the AI DevOps Engineer's core. Swap get_weather for kubectl_get, terraform_plan, and open_pr; make the human-in-the-loop gate wrap every state-changing action; and you have the agent. The read-only/gated tool split you'd design here is the exact safety model in the capstone. See the full tool surface → · the safety model →

Knowledge check check yourself

✓ Knowledge check

In the agentic loop, what are the two exit conditions, and why can the model never run a tool itself?

Show answer
The loop exits when the model returns stop_reason end_turn (it's finished) or when your hard MAX_STEPS cap trips. The model can only *ask* to use a tool by returning a tool_use block; your code runs the real function and feeds the result back — that hand-off is what keeps execution under your control.
✓ Knowledge check

When a destructive tool is denied by the human-in-the-loop gate, the code returns 'DENIED by human. Suggest an alternative.' as the tool result rather than raising an error. Why is feeding the denial back as a tool result the right design?

Show answer
Because the model reads the tool result and adapts — it proposes a different course instead of crashing or silently failing. Returning the denial as data keeps the agent in its normal loop and lets it react to the refusal, which is exactly the behavior you want from a gated action.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in