AI EngineeringZero to ProductionHome·About·What’s new·Contact
Part V · Build Lab B

The Tool Registry & the Agent Loop

Now you build the working brain: a registry of tools (each tagged with a risk class) and the agentic loop that lets Claude call them. By the end you'll have a real read-only diagnostician that inspects the mock cluster and explains what's wrong — your first end-to-end run.

⏱️ ~60 min🔑 API key needed from Step 4✅ test cases included
⚙️ 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 engine 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

  • Model a tool as data: schema + risk class + implementation, in one registry.
  • Reuse the Chapter 4 agent loop, now driven by the registry.
  • Run a real diagnosis against the mock cluster and read the audit trail.
  • Test the loop's behavior deterministically.

Where this fits intermediate

Up to now your DevOps agent has been all skeleton and no pulse. Lab 8a gave you a mock cluster, a risk taxonomy, and typed schemas — real scaffolding, but nothing that thinks. This is the lab where the thing wakes up: for the first time the model reads the state of a broken cluster and decides, on its own, what to look at next. That moment — code you wrote handing control to a model that then hands control back — is the whole reason "agent" is a different word from "script".

Here's the mental model to carry through the rest of this lab. An agent is a conversation with a hard cap: you tell the model about a problem and hand it a menu of tools; it either answers or asks to use a tool; you run the tool and paste the result back; repeat until it's done or you hit the ceiling. Everything in this lab is just those two moving parts — the menu (the tool registry, Steps 1–2) and the conversation manager (the loop, Step 3). If you keep that two-part picture in your head, none of the code below will surprise you.

The mistake people make coming into this lab is assuming "wiring up an LLM to run commands" is the scary part. It isn't — the API call itself is a dozen lines. The genuinely hard, career-relevant skill is keeping the model on a leash: it never touches your systems directly, it can only ask your code to run tools you registered, and every tool carries a risk tag your program controls. That separation — model proposes, your harness disposes — is what makes an agent something you'd actually let near a cluster. Get it right here on read-only tools and the write actions in Lab 8c become a small, safe addition rather than a leap of faith.

In Lab 8a everything ran offline. This lab makes the first API calls — the loop sends the mock cluster's data to Claude, which reasons about it and calls read-only tools to investigate. Still 100% safe: only read-only tools are wired up here; the safety gate and write actions come in Lab 8c.

an agent = a bounded conversation: model proposes, your harness disposes 🧠 model (think) answer, or ask for a tool (tool_use) 🛠️ harness (act) runs the registered read-only tool 📄 result (observe) tool_result fed back to the model tool_use loop back — model reads the result, decides the next move stop_reason == "end_turn" ✅ final diagnosis 🚧 MAX_STEPS cap seatbelt — always ends model is done → the loop is guaranteed to terminate: an answer, or the step cap Model proposes, harness disposes — on a leash. The model can only ask for a tool you registered; your code runs it and feeds the result back, round and round, until the model answers (end_turn) or the MAX_STEPS cap forces the loop to stop.
🗺️ How to read this diagram

Follow the top row left to right — that's one lap of the loop — then drop to the bottom row for the two ways a lap can end.

  • The purple box is the model thinking: it either answers or emits a tool_use request. The blue arrow hands that request to the light-blue harness box — your code acting by running the registered read-only tool.
  • The green result box is the observation: the tool's output is packaged as a tool_result and looped back to the model along the long blue arrow, so its next turn is informed by what it just found.
  • The two bottom boxes are the exits. The dashed arrow down to stop_reason == "end_turn" ends the lab happily with the green final diagnosis. The red MAX_STEPS box is the payoff element: even if the model never stops asking, the cap guarantees the loop terminates.

In short: the model never touches your systems — it only requests tools your harness runs, and a hard step cap means the whole thing always ends.

Step 1 · Model a tool as data intermediate

Every tool leads a double life, and if you don't design for that you'll leak something you shouldn't. The model needs to know a tool exists and how to call it. Your safety machinery needs to know how dangerous it is. Your runtime needs the actual function to execute. Cram all of that into whatever you send the model and you've just told the model the exact risk tag it's about to be gated on — the one piece of information you least want it negotiating around. So the first design decision of the whole agent is quietly the most important: what does the model see, and what stays yours?

Think of a tool the way an airline thinks of a boarding pass. The passenger's copy shows the flight, the gate, the seat — everything they need to act on. It does not show the fare class rules, the crew's override codes, or the actual mechanism that opens the jet bridge. Same trip, two views: a public face and a private back-office. A Tool here is exactly that — one object with a public face (spec(), the name/description/schema the model reads) and a private back-office (risk and run, which never leave your process). Bundling both halves in one place is deliberate: it means there is a single object where the loop, the tests, and the safety gate can all ask "what is this tool and how careful must I be with it?"

Why does this earn a whole step rather than a stray sentence? Because the alternative — the shape beginners reach for — is to keep three loose parallel lists: a list of API tool specs here, a dict of risk levels there, a dict of functions somewhere else, all keyed by name and all quietly assumed to stay in sync. They don't stay in sync. Someone adds a tool to two of the three lists, ships it, and now there's a live tool the gate has no risk entry for — a silent hole in your safety model that no error will ever announce. Keeping name, description, input_schema, risk, and run welded into one dataclass makes that class of bug unrepresentable: you cannot register a tool without giving it a risk, because the field is right there in the constructor.

The common trap to name explicitly: sending the model your whole tool object because it's convenient. Don't. The model only ever gets what spec() chooses to return, and spec() returns three keys precisely so risk and run stay invisible. A tool bundles three things: the schema Claude sees, the risk class (from Lab 8a), and the Python function that runs it. Keeping them together in one object is what lets the safety gate later look up any tool's risk in one place.

one Tool object · two views of the same tool Tool (one dataclass) name description input_schema risk run spec() 🤖 what the model sees name · description · schema 🔒 stays in your process risk (gate) · run (execute) the model never learns the risk tag, nor holds the real function One object, two views. All five fields live in a single Tool, but spec() hands only the top three to the model; risk and run never leave your program.
🗺️ How to read this diagram

Read it left to right: the tall box on the left is the one object; the two boxes on the right are the two audiences that object serves.

  • The purple box is the single Tool dataclass holding five fields. The top three (name, description, input_schema) are blue; the bottom two (risk, run) are red because they are dangerous to expose.
  • The solid blue arrows are what spec() returns — only the three blue fields flow to the green "what the model sees" box, exactly the shape the Anthropic tools=[...] parameter wants.
  • The dashed grey arrows show risk and run staying home in the red "stays in your process" box: the gate reads risk, your loop calls run, and the payoff is that the model never learns the tag it might argue around.

In short: one dataclass, but a public face (spec()) and a private back-office — bundling both halves is what makes an untagged tool impossible.

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 8b · Step 1
agent/tools.pyfrom dataclasses import dataclass
from typing import Callable
from .schemas import RiskClass
from mock import cluster, actions

@dataclass
class Tool:
    name: str
    description: str
    input_schema: dict
    risk: RiskClass                      # ← the safety-critical field
    run: Callable

    def spec(self) -> dict:
        # the shape Claude's tools=[...] parameter wants
        return {"name": self.name, "description": self.description,
                "input_schema": self.input_schema}

TOOLS: dict[str, Tool] = {}
def register(tool): TOOLS[tool.name] = tool

def _obj(props, required):
    return {"type":"object", "properties": props, "required": required}
▶ How this works

Before an AI model can use a tool, we have to describe that tool in a way both our program and the model understand. This block defines a single container — a Tool — that packs everything about one tool into one object: what it's called, what it does, what inputs it takes, how dangerous it is, and the actual code that runs it. A @dataclass is just Python's shortcut for "a simple record with named fields".

  1. The five lines under class Tool: are the fields every tool must have: name (short id like "kubectl_logs"), description (plain-English text the model reads to decide when to use it), input_schema (what arguments it accepts), risk (how dangerous — the field the safety gate will check later), and run (the real Python function to execute).
  2. risk: RiskClass is called out with a comment as the safety-critical field. Keeping the risk level glued to the tool means later code can look up "how dangerous is this tool?" in one place — you can never accidentally run a tool without knowing its risk.
  3. def spec(self) returns a small dictionary with just name, description, and input_schema. That is exactly the shape the Anthropic API's tools=[...] parameter expects — so spec() is how we hand a tool to the model without leaking our internal risk and run fields.
  4. TOOLS: dict[str, Tool] = {} is an empty registry — a phone book that maps each tool's name to its Tool object. register(tool) simply adds a tool to that book. _obj(...) is a tiny helper that builds the standard "object with these properties" JSON schema, so we don't repeat it for every tool.

What the output means: Nothing prints — this file only defines the shape of a tool and an empty registry. It's the foundation the next steps fill in.

Try this: Ask yourself: which field does the model actually see, and which stays private to your program? (Hint: only the three fields returned by spec() go to the model; risk and run never leave your machine.)

Step 2 · Register the read-only tools intermediate

The single highest-leverage thing you will write in this whole lab is not code — it's a sentence. Specifically, each tool's description string. Engineers instinctively treat those descriptions as throwaway documentation, the kind of comment you dash off and never reread. In an agent they are the opposite: the description is the only instruction the model gets about when to reach for a tool, so its wording literally decides the order in which your agent investigates an incident. Change "lists pods" to "List pods... Use FIRST when diagnosing" and the agent's entire behaviour shifts. You are programming the model in English.

Here's the analogy that makes it click. Imagine handing a sharp new hire a toolbox on their first day with no context. If every tool just has its name etched on it — "wrench", "meter" — they'll pick things up in a random or nervous order and maybe grab the wrong one first. Now imagine each tool has a tiny label: "check this reading FIRST before touching anything", "only use once you've confirmed the power is off". Suddenly the newcomer works like a veteran, in a sensible sequence, because the tools themselves told them how to think. Your tool descriptions are those labels, and the model is that new hire — capable, but entirely dependent on what you wrote on the tin.

There's a deliberate ordering to which tools we register too, and it's a safety principle, not an accident: give the agent eyes before you ever give it hands. Everything registered in this lab is READ_ONLY — it can look at pods, pull logs, list recent deploys, read metrics and alerts, but it cannot change a single thing. That's not a limitation we're apologising for; it's the correct rung to start on. An agent that can only observe can still be enormously useful (it diagnoses the whole incident in Step 4) and it is incapable of making anything worse. The write actions — the tools with hands — wait until Lab 8c, behind the safety gate, on purpose.

The mistake to avoid here is vague, model-facing descriptions. A tool labelled just "gets pods" is a tool the model will call late, sporadically, or not at all — and then you'll blame the model for investigating in a weird order when the real bug was your prose. Write descriptions the way you'd brief a colleague: say what it does and when to use it. Give the agent eyes before hands. Each read-only tool wraps a mock-cluster accessor and is tagged READ_ONLY. Note how the description tells the model when to use it — that's what makes the agent investigate in a sensible order.

vague: "lists pods" description: no "when" logs? deploys? pods? random order · may skip precise: "Use FIRST…" description: what + when pods logs deploys sensible order · every time same model, same tools — only the description prose changed you program the model in English The description is the program. Vague wording ("lists pods") leaves the model guessing the order; "Use FIRST when diagnosing" produces the reliable pods → logs → deploys sequence — with no code change.
🗺️ How to read this diagram

The dashed line splits the same tool two ways: on the left its description is vague, on the right it says when to use it. Read each half top-to-bottom — description, then the order the agent investigates in.

  • The left (red) side is the vague description. The three grey boxes below it are jumbled — logs?, deploys?, pods? — because the model has no cue about sequence, so it may probe in a poor order or skip a tool entirely.
  • The right (green) side is the precise "Use FIRST…" description. The arrows chain the boxes in a fixed left-to-right order — pods → logs → deploys — the sensible investigation path.
  • The payoff is at the bottom: same model, same tools — the only thing that changed was the prose. That is why "you program the model in English" is literal, not a metaphor.

In short: a tool's description string is the single highest-leverage thing you write — it decides the order your agent thinks in.

Lab 8b · Step 2
agent/tools.py (continued)register(Tool(
    "kubectl_get_pods",
    "List pods with status and restart counts. Use FIRST when "
    "diagnosing — it shows what's unhealthy.",
    _obj({"namespace": {"type":"string"}}, []),
    RiskClass.READ_ONLY,
    lambda namespace="staging": cluster.get_pods(namespace),
))
register(Tool(
    "kubectl_logs",
    "Fetch recent log lines for a pod. Use to find the actual error.",
    _obj({"pod": {"type":"string"}, "namespace": {"type":"string"}}, ["pod"]),
    RiskClass.READ_ONLY,
    lambda pod, namespace="staging": cluster.get_logs(pod, namespace),
))
# also: kubectl_get_events, recent_deploys — same pattern
▶ How this works

Here we actually create and register two real tools. Each register(Tool(...)) call fills in the five fields from Step 1 for one tool and drops it into the TOOLS registry. These are read-only tools — they only look at the mock cluster, they never change anything — so they are completely safe to let the model call.

  1. The first tool is named "kubectl_get_pods". Read its description: "List pods with status and restart counts. Use FIRST when diagnosing…". That sentence is written for the model to read — the words "Use FIRST" nudge the model to check pod health before anything else. A good description is how you steer an agent's behaviour.
  2. _obj({"namespace": {"type":"string"}}, []) is the input schema: it says "this tool accepts one optional argument called namespace that is a string". The empty list [] means no argument is required.
  3. RiskClass.READ_ONLY tags the tool as safe. The lambda on the next line is the actual code that runs when the model calls the tool — here it just forwards the request to cluster.get_pods(namespace) and defaults namespace to "staging".
  4. The second tool, "kubectl_logs", follows the identical recipe but requires a pod argument (note ["pod"] in the schema). The trailing comment says the other tools use the same pattern — once you understand one register(Tool(...)) block, you understand them all.

What the output means: Again nothing prints; each call quietly adds one tool to the TOOLS registry. After this file runs, TOOLS holds several named, described, risk-tagged tools ready for the model to choose from.

Try this: Reword kubectl_get_pods's description to be vague (e.g. just "lists pods") and imagine the effect: with weaker wording the model is less likely to call it first. The description is the instruction manual you give the model.

Reuse from Chapter 3Later you'll add a search_docs tool that calls your Chapter 3 retriever — turning RAG into an agent tool. In Lab 8c we ground the agent a simpler way (runbook injected into the system prompt), but the tool approach works too. Same building blocks, composed differently.
🖥️ Add monitoring tools too — all read-onlyThe starter kit also registers monitoring tools against a mock Grafana/Kibana/CloudWatch stack (mock/monitoring.py): firing_alerts, get_metric, list_dashboards, get_dashboard, search_logs. They follow the exact same register(Tool(...)) pattern and are all RiskClass.READ_ONLY — so the agent can triage a "something's wrong in prod" page (start from alerts → confirm with metrics → search logs) while staying at the safe OBSERVE rung. Try: python cli.py "something is wrong with checkout in staging — investigate".

Step 3 · The agent loop advanced

People expect the "agent loop" to be the most mysterious code in the file. It's about fifteen lines, and once you see it you can't un-see how simple it is. There is no hidden orchestration engine, no magic. An agent is a for loop that keeps a conversation going: call the model, check whether it wants a tool, run the tool if so, paste the answer back, go around again. The intelligence lives in the model; the loop is just the plumbing that lets the model act and then react to what it found. Demystifying this is worth a section on its own, because half of "understanding agents" is realising there's nothing to be intimidated by.

The rhythm has a name worth knowing — think, act, observe — and a good analogy is a doctor working through a diagnosis. The doctor thinks ("could be the heart"), acts ("order an ECG"), and then observes the result before deciding the next test. They don't script all the tests up front; each result reshapes the next move. That feedback cycle is exactly what your loop implements: the model thinks and emits a tool_use, your code acts by running the tool, and the result goes back as an observation the model reads before its next turn. It's the loop, not any single API call, that lets an agent chase a root cause it couldn't have predicted from the incident text alone.

Now the part that separates a toy loop from one you'd trust: it must be bounded. A model can, and occasionally will, get into a rut where it keeps asking for one more tool, forever. Without a ceiling that's an infinite loop burning tokens and money against your API key. The MAX_STEPS cap is the seatbelt — the loop is guaranteed to terminate after a fixed number of passes no matter what the model does. This is the single most common thing missing from beginners' agent loops, and it is non-negotiable: every agent loop you ever write gets a hard step cap, the same way every recursion gets a base case.

The other three rules are subtler and, if you break them, the API rejects your request outright with a cryptic 400. They're the message-protocol contract from Chapter 4: append the model's turn to the conversation verbatim before you add any results; every tool_result you send back must carry the tool_use_id of the request it answers, so the model can match reply to request; and all of a turn's results go back in one user message, not dribbled out separately. Miss any of these and you'll meet the errors in the Troubleshooting table below. This is the Chapter 4 loop, driven by the registry. For now it runs every requested tool directly (all are read-only, so it's safe). In Lab 8c you'll insert the policy gate between "model wants tool" and "tool runs".

the messages list grows one turn at a time user the incident text assistant (verbatim) tool_use id=tu_1 user (one message) tool_result id=tu_1 tool_use_id must match ↑ appended list is sent whole to the next model call — loop around break any rule → API 400 (see Troubleshooting) The three-rule message contract. Append the assistant turn verbatim, answer each tool_use with a tool_result carrying the same tool_use_id, and batch all results into one user message — then resend the whole list.
🗺️ How to read this diagram

Read the top row left to right — it is the messages list growing by one entry each turn — then follow the arrows back down for the two things that must line up.

  • The blue user box is where it starts: the incident text. The purple assistant box is the model's reply appended verbatim; it carries a tool_use block with an id (tu_1).
  • The green user box is your reply: a single message holding the tool_result. The amber dashed arrow shows the load-bearing rule — its tool_use_id must equal the request's id so the model can match answer to question.
  • The long blue arrow loops the whole grown list back into the next model call. The red bar is the payoff/warning: violate any of the three rules and the API rejects the call with a 400.

In short: the loop is just a message list you grow correctly — verbatim assistant turn, matching tool_use_id, one user message — and hand back each pass.

Lab 8b · Step 3

The read-only agent engine — in Claude or OpenAI

This is the Chapter 4 loop again, driving the tool registry. The four moves are the same in both SDKs — only the protocol differs. Claude branches on stop_reason, matches by tool_use_id, and batches tool_result blocks into one user message; OpenAI keeps an input list, loops while resp.output still has function_call items, json.loads the arguments, and appends function_call_output items keyed by call_id. Your Tool.spec() emits whichever tool shape the vendor expects. Toggle the tab to switch SDK.

agent/engine.py (read-only version)from anthropic import Anthropic
from agent.tools import TOOLS
client = Anthropic()
MAX_STEPS = 8

def run(incident: str):
    system = ("You are a careful SRE. Diagnose the incident using the "
              "read-only tools. Cite the evidence you find.")
    messages = [{"role":"user", "content": incident}]
    specs = [t.spec() for t in TOOLS.values()]

    for _ in range(MAX_STEPS):                # hard cap — always terminates
        resp = client.messages.create(model="claude-opus-4-8",
                 max_tokens=1500, system=system, tools=specs, messages=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})
        results = []
        for b in resp.content:
            if b.type == "tool_use":
                out = TOOLS[b.name].run(**b.input)     # Lab 8c gates this
                results.append({"type":"tool_result",
                                "tool_use_id": b.id, "content": str(out)})
        messages.append({"role":"user", "content": results})
    return "Stopped: hit step limit."
▶ How this works

This is the agent loop — the beating heart of the whole capstone. An "agent" is just a loop that: (1) asks the model what to do, (2) if the model asks to use a tool, runs that tool, (3) feeds the tool's result back to the model, and (4) repeats until the model says it's finished. The model never touches your systems directly — your loop runs the tools on its behalf.

  1. MAX_STEPS = 8 and for _ in range(MAX_STEPS): set a hard cap of 8 passes. The comment says always terminates — this guarantees the loop can never run forever, even if the model keeps asking for more tools. A step cap is a basic safety net for every agent.
  2. The running conversation (messages in Claude, the input list in OpenAI) starts with the user's incident. Each pass calls the model — client.messages.create(...) or client.responses.create(...) (toggle the tab) — sending the system instructions, the tools=specs list (from Step 2's registry), and the whole conversation so far. The model reads it all and replies.
  3. if resp.stop_reason == "end_turn": means the model is done — it has no more tools to call and just wants to answer. We pull out its text reply and return it, ending the loop.
  4. Otherwise the model asked to use one or more tools. We first append the model's turn to messages verbatim, then loop over resp.content: for each block where b.type == "tool_use", we run the matching tool with TOOLS[b.name].run(**b.input) and collect the result.
  5. Each result is packaged as a tool_result carrying the same b.id (its tool_use_id) so the model knows which request it answers. All results go back in one user message (messages.append(...)), and the loop repeats. If we ever exhaust all 8 steps, the final return reports it hit the step limit.

What the output means: When run, this returns Claude's final diagnosis as plain text — after it has silently called whatever read-only tools it needed along the way. If the model gets stuck looping, you'd instead see "Stopped: hit step limit."

Try this: Trace one full lap in your head: user asks → model says "call kubectl_get_pods" → loop runs it → result appended → model reads result and either answers (end_turn) or asks for another tool. That call-run-feedback cycle is what makes it an agent.

The three loop rules from Chapter 4 still applyAppend the assistant turn verbatim before results · every tool_result carries its tool_use_id · all results in one user message. The finished engine.py in the starter kit already includes the Lab 8c additions (gate + audit) — this excerpt is the read-only core.

Step 4 · Run your first diagnosis advanced

This is the payoff moment — the first time you type a problem in plain English and watch software figure out the answer without being told how. Up to now you've built parts. Here they run together, against the live model, on a real (mock) broken cluster. What makes it land emotionally is what you don't do: you never tell it to check the pods, then read the logs, then look at deploys. You hand it one sentence — "checkout-api keeps restarting" — and it decides the investigation plan itself.

The right way to read the output is to treat it like a detective's case file, not a chatbot reply. Notice the shape of what comes back: a conclusion (the diagnosis), the reasoning that connects the clues (a crash-looping pod, a specific log error, a recent deploy that changed a secret), and — crucially — an audit log listing every tool it actually called, in order, each tagged [read_only]. That audit trail is not decoration. In a real on-call situation the first question anyone asks an automated actor is "what exactly did you do?" An agent that can't answer that is one you can't deploy; an agent that prints its every move is one you can trust and review. You're seeing the accountability layer, born read-only, that everything in Lab 8c will build on.

Read the three audit lines top to bottom — pods, then logs, then deploys — and connect them straight back to Step 2. That order wasn't luck and it wasn't hard-coded; it fell out of the "Use FIRST" wording you put in kubectl_get_pods's description. This is the whole thesis of the lab made visible: your prose shaped the machine's behaviour. The trap to avoid at this stage is treating a good run as proof of correctness — one lucky diagnosis is an anecdote, not evidence. That's exactly why Step 5 adds tests and Lab 8d adds real evals: to turn "it worked when I tried it" into "it works, measurably, every time." For now, savour the run, then go make it trustworthy. Now you need your API key (from the course .env). A quick runner:

you give one sentence · the agent chooses the plan 🗣️ incident (English) "checkout-api keeps restarting" 🧠 agent decides order not scripted audit log — every action, in order: ✓ get_pods ✓ logs ✓ recent_deploys every line tagged [read_only] — nothing changed cited diagnosis: bad DB secret from a recent deploy One sentence in, a case file out. The agent picks the tool order itself — pods → logs → deploys — logs each call with a [read_only] tag, then connects the clues into a cited diagnosis without touching anything.
🗺️ How to read this diagram

Read top to bottom: your one input, the agent's decision, the ordered trail it leaves, and the conclusion it reaches.

  • The blue box is all you supply — a plain-English incident. The purple box is the key idea: the agent, not you, decides which tools to call and in what order.
  • The green chain is the audit log, left to right in the exact order the tools ran: get_pods → logs → recent_deploys. That ordering is the "Use FIRST" wording from Step 2 made visible.
  • The payoff is the two lines at the bottom: every call is tagged [read_only] (nothing was changed), and the trail leads to a cited diagnosis — the accountability layer you can review and trust.

In short: you hand it a sentence; it hands you back an ordered, read-only case file ending in a reasoned, cited answer.

Lab 8b · Step 4
terminalcd devops-agent
python cli.py "checkout-api-7d9f in staging keeps restarting"

Expected (the model investigates, then concludes):

🔧 Running at rung: OBSERVE
--------------------------------------------------
The pod checkout-api-7d9f is in CrashLoopBackOff. Its logs show
"password authentication failed for user 'checkout'", and a deploy
18 minutes ago changed the DATABASE_URL secret reference. The likely
root cause is a broken database credential from that change. Suggested
fix: correct the secret and roll the deployment — do not just restart.
--------------------------------------------------
📋 Audit log (every action attempted):
✓ kubectl_get_pods({'namespace': 'staging'}) [read_only]
✓ kubectl_logs({'pod': 'checkout-api-7d9f'}) [read_only]
✓ recent_deploys({'namespace': 'staging'}) [read_only]

That's a real agent: it chose which tools to call, in what order, connected the log error to the recent deploy, and never changed anything.

▶ How this works

Now you actually run the agent from a terminal. This is the first time the code talks to the real model over the internet, so an API key is needed from here on. You type the incident in plain English and let the agent investigate.

  1. cd devops-agent moves into the project folder. python cli.py "checkout-api-7d9f in staging keeps restarting" launches the program and passes your incident description as the input the loop will send to the model.
  2. You did not tell it which tools to run or in what order. The agent decides that itself using the tool descriptions from Step 2 — that is the whole point of an agent.

What the output means: The OBSERVE rung line confirms it stayed at the safe, read-only level. The paragraph is the model's diagnosis: it connected the crash-looping pod, the "password authentication failed" log line, and a recent deploy that changed the database secret — then suggested a fix without touching anything. The audit log below lists every tool it called (all [read_only]), giving you a full trail of what the agent did.

Try this: Read the three audit lines in order — pods, then logs, then deploys. That ordering came straight from the "Use FIRST" wording in the tool descriptions. Change the incident text and watch which tools the agent chooses to call.

Step 5 · Test the loop expert

Testing an agent feels paradoxical at first: how do you write a pass/fail test for a system whose whole job is to be non-deterministic? The model can phrase the same correct diagnosis a hundred different ways, and it costs money and network time every time you call it — so the naive instinct ("just run the agent and check the answer") is both flaky and expensive. The resolution is the most important testing lesson in the whole capstone: most of an agent isn't the model at all. The registry, the schemas, the risk tags, the block-parsing logic — that's ordinary, deterministic Python you can test the ordinary, deterministic, free way.

So the discipline is to split your tests along a bright line: tests that need an API key and tests that don't. Picture it like testing a car. You don't fire up the engine and drive onto the motorway to check that the seatbelts latch, the doors seal, and the dashboard lights work — you check all of that in the garage, engine off, in seconds. Only a few tests genuinely require a running engine and a test track. Agent tests are the same: the vast majority (does every tool have a risk class? does the registry return real cluster data? does the block parser emit one result per tool call?) run in the "garage" with no model, no key, no cost — so they can gate every single commit in CI. The handful that truly exercise the model's reasoning are the "test track" runs you do deliberately, not on every push.

Don't dismiss the no-key tests as trivial box-ticking, either — the trap is assuming the cheap tests are the unimportant ones. The exact opposite is true here. The "every tool has a risk class" test is one line and needs no API key, yet it is load-bearing safety infrastructure: the Lab 8c gate makes its allow/ask/block decision entirely off tool.risk, so a single tool registered with a typo'd or missing risk is a silent hole the gate can't see. A free, instant test that closes that hole on every commit is worth more than a dozen slow end-to-end runs. Cheap and load-bearing is the sweet spot you're aiming for. Two kinds of test. The registry test needs no API key; the diagnosis test does (it runs the model), so keep those separate so CI can run the free ones always.

🅰️ no API key · the garage every tool has a risk class registry returns cluster data spec() hides risk & run parser: one result per tool_use free · instant · deterministic runs on EVERY commit in CI 🅱️ needs key · the test track does the diagnosismention "database"? calls the real model slow · costs money · flaky wording run deliberately, not per push Split tests by whether they need the model. The many cheap, deterministic checks (left) gate every commit; the few real-model runs (right) are slow and costly, so you run them on purpose — not on every push.
🗺️ How to read this diagram

The dashed line divides your test suite into two tiers. Read the left panel (cheap) first, then the right (expensive) — the split is by one question: does the test call the model?

  • The left green panel is the "garage": four deterministic checks — risk tags, registry wiring, spec() hiding risk/run, and the block parser — that touch only ordinary Python, no key.
  • The right red panel is the "test track": the handful of tests that genuinely exercise the model's reasoning (e.g. does the diagnosis mention "database"). The amber inner box is the one model-calling assertion.
  • The payoff is the two footers: the left tier is free, instant and runs on every commit; the right tier is slow, costs money and flaky, so you run it deliberately. Most of an agent is the cheap, left-hand kind.

In short: most of an agent isn't the model — test that part free on every commit, and save the paid model runs for when you truly need them.

Lab 8b · Step 5
tests/test_mock_and_tools.py (add)from agent.tools import TOOLS
from agent.schemas import RiskClass

def test_every_tool_has_a_risk_class():          # no API key
    for name, tool in TOOLS.items():
        assert isinstance(tool.risk, RiskClass), f"{name} missing risk"

def test_readonly_tool_runs_and_returns_data():   # no API key
    pods = TOOLS["kubectl_get_pods"].run(namespace="staging")
    assert any(p["status"] == "CrashLoopBackOff" for p in pods)
▶ How this works

Good agents come with tests. The clever move here is splitting tests into ones that need no API key (fast, free, always run) and ones that call the model (slower, cost money). These two tests are the free kind — they check the registry and tool wiring without ever contacting Claude.

  1. test_every_tool_has_a_risk_class loops over every tool in the TOOLS registry and asserts that its risk really is a RiskClass. The message f"{name} missing risk" is what prints if one is missing — so no tool can ever sneak into the registry untagged and later slip past the safety gate.
  2. test_readonly_tool_runs_and_returns_data actually calls the kubectl_get_pods tool and checks the returned pods include one in "CrashLoopBackOff". This proves the registry is correctly wired to the mock cluster — the tool runs and gives back real data.
  3. An assert is a test's way of saying "this must be true". If it is, the test passes silently; if not, the test fails and shows your message — that's how you know something broke.

What the output means: Run with pytest, both tests pass silently (no output means success). Because neither talks to the model, they run in a fraction of a second and cost nothing — safe to run on every commit in CI.

Try this: Temporarily change a tool's risk to a plain string like "safe" instead of a RiskClass, and rerun — the first test will fail and print … missing risk, showing you exactly how the guard catches mistakes.

✅ Test cases for Lab 8b
TestNeeds API key?Proves
every tool has a risk classNoNothing can slip past the gate untagged
read-only tool returns dataNoRegistry ↔ mock wiring works
diagnosis mentions "database" (Lab 8d eval)YesThe loop actually reasons correctly

Troubleshooting expert

⚠️ Common issues & fixes
SymptomCauseFix
Agent answers without calling any toolTool descriptions too vague, or incident too genericMake descriptions say when to use the tool; name the pod in the incident
Infinite-ish loop / hits step limitModel keeps re-calling toolsConfirm MAX_STEPS cap is present; check you append the assistant turn before results
400 tool_use_id ... without tool_resultA requested tool result wasn't returnedReturn one tool_result per tool_use block, all in one user message
KeyError in TOOLS[b.name]Model called a tool name not in the registryOnly pass spec() for registered tools; the model can only call what you send
AuthenticationErrorNo/invalid API keycp ../.env.example ../.env and paste your key; ensure venv active
Answer ignores the recent deployModel didn't call recent_deploysStrengthen that tool's description; or lower the incident ambiguity. Lab 8c's runbook grounding also helps

🪜 Practice ladder beginner → industry

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

Exercise 1 · Model a tool as a dataclass with a private risk fieldBeginner

Context: A tool the model can call and a tool the gate can police are two different views of the same object. The Tool dataclass keeps the risk and the real function private from the model on purpose.

Your task: Reproduce the Tool dataclass from agent/tools.py, including its spec() method, and explain which fields spec() deliberately omits.

Requirements:

  • Fields for name, description, input schema, risk class, and the callable to run
  • spec() returns only name, description and input schema — the shape the API's tools=[...] expects
  • risk and run are excluded from spec()
  • Explain that the model never learns the risk the gate checks, nor holds the real function
  • Pure definition — no API key

💡 Hint: The model only needs to know a tool exists and how to call it; the risk and the implementation are the harness's business, not the model's.

Show solution
from dataclasses import dataclass
from typing import Callable
from agent.schemas import RiskClass

@dataclass
class Tool:
    name: str
    description: str
    input_schema: dict
    risk: RiskClass                      # ← the safety-critical field
    run: Callable

    def spec(self) -> dict:
        # the shape Claude's tools=[...] parameter wants
        return {"name": self.name, "description": self.description,
                "input_schema": self.input_schema}

One Tool bundles everything about a tool: the name/description/input_schema the model sees, plus risk (checked by the gate) and run (the real Python callable). spec() returns only the first three keys — exactly what the Anthropic tools=[...] parameter expects — so risk and run stay private to your program. The model never learns the risk tag it might argue around, nor holds the real function. This is a pure definition; no API key needed.

Exercise 2 · Build the registry and register a read-only toolIntermediate

Context: Tools live in a central registry so the loop, the gate and the tests all see the same set. Registering the first read-only tool wires the registry to the mock cluster.

Your task: Add the TOOLS registry, the register() helper and the _obj() schema helper, then register the kubectl_get_pods tool as the lesson does.

Requirements:

  • TOOLS is a name→Tool dict; register() inserts into it
  • _obj(props, required) builds a JSON-schema object with the given properties and required list
  • Register kubectl_get_pods as RiskClass.READ_ONLY, backed by the mock's get_pods
  • Its description nudges the model to investigate first (e.g. "Use FIRST")
  • An empty required list means no mandatory arguments

💡 Hint: The description is written for the model's benefit — wording like "Use FIRST" shapes the order in which it reaches for tools.

Show solution
from mock import cluster
from agent.schemas import RiskClass
from agent.tools import Tool   # (or same module)

TOOLS: dict[str, Tool] = {}
def register(tool): TOOLS[tool.name] = tool

def _obj(props, required):
    return {"type": "object", "properties": props, "required": required}

register(Tool(
    "kubectl_get_pods",
    "List pods with status and restart counts. Use FIRST when "
    "diagnosing — it shows what's unhealthy.",
    _obj({"namespace": {"type": "string"}}, []),
    RiskClass.READ_ONLY,
    lambda namespace="staging": cluster.get_pods(namespace),
))

TOOLS is a name → Tool phone book, and register() drops one in. _obj({"namespace": {"type":"string"}}, []) builds the JSON Schema: one optional string argument (the empty [] means nothing is required). The description is written for the model — the words "Use FIRST" nudge it to check pod health before anything else, which is how sensible investigation ordering emerges. The tool is tagged RiskClass.READ_ONLY and its lambda forwards to the mock accessor. Runnable pure Python; no API key.

Exercise 3 · Register kubectl_logs with a required argumentAdvanced

Context: Some tools can't run without an argument. Marking pod as required in the schema, then testing the registered tool offline, proves the registry-to-mock wiring end to end.

Your task: Register the kubectl_logs tool making pod a required argument, then write a no-API-key test proving the registered tool runs and returns cluster data.

Requirements:

  • Schema declares pod (and optionally namespace) with pod in the required list
  • Invoke the tool via the registry (TOOLS[name].run(...)), not the mock directly
  • Assert the crash-looping pod's logs come back
  • Assert the known root-cause string is present in those logs
  • Runs offline — no API key

💡 Hint: Calling through TOOLS[...] rather than the mock is the point: it's the registry wiring, not just the mock, that you're proving.

Show solution
register(Tool(
    "kubectl_logs",
    "Fetch recent log lines for a pod. Use to find the actual error.",
    _obj({"pod": {"type": "string"}, "namespace": {"type": "string"}}, ["pod"]),
    RiskClass.READ_ONLY,
    lambda pod, namespace="staging": cluster.get_logs(pod, namespace),
))

# tests/test_mock_and_tools.py (add) — no API key
def test_readonly_tool_runs_and_returns_data():
    pods = TOOLS["kubectl_get_pods"].run(namespace="staging")
    assert any(p["status"] == "CrashLoopBackOff" for p in pods)

def test_logs_tool_finds_error():
    logs = TOOLS["kubectl_logs"].run(pod="checkout-api-7d9f")
    assert "password authentication failed" in logs

The only difference from kubectl_get_pods is ["pod"] in the schema, which makes pod mandatory — the model must supply it. The tests call the tools' run callables directly (no model in the loop), proving the registry ↔ mock wiring works: kubectl_get_pods returns the crash-looping pod, and kubectl_logs surfaces the real DB-auth error. These run in a fraction of a second and need no API key, so CI can run them on every commit.

Exercise 4 · Write the read-only agent loopExpert

Context: This is the manual tool-use loop from Chapter 4, hardened: a bounded loop that sends tool specs, replays the assistant turn verbatim, and returns matched tool results until the model is done.

Your task: Reproduce agent/engine.py's read-only run() loop: the MAX_STEPS cap, the client.messages.create call with the tool specs, and the tool_use→tool_result handling. Use the exact model ID the lesson uses.

Requirements:

  • A MAX_STEPS cap guarantees the loop terminates
  • Call client.messages.create with the system prompt, the tool specs, and the running messages, using model claude-opus-4-8
  • Return the text when stop_reason == "end_turn"
  • Append the assistant turn verbatim, then run each tool_use and return a tool_result carrying the matching tool_use_id
  • Send all tool results in a single user message before looping
  • Needs an API key and the anthropic SDK

💡 Hint: The three contracts from Chapter 4 are load-bearing: assistant turn appended verbatim, every result tagged with its tool_use_id, and all results batched into one user message.

Show solution
from anthropic import Anthropic
from agent.tools import TOOLS
client = Anthropic()
MAX_STEPS = 8

def run(incident: str):
    system = ("You are a careful SRE. Diagnose the incident using the "
              "read-only tools. Cite the evidence you find.")
    messages = [{"role": "user", "content": incident}]
    specs = [t.spec() for t in TOOLS.values()]

    for _ in range(MAX_STEPS):                # hard cap — always terminates
        resp = client.messages.create(model="claude-opus-4-8",
                 max_tokens=1500, system=system, tools=specs, messages=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})
        results = []
        for b in resp.content:
            if b.type == "tool_use":
                out = TOOLS[b.name].run(**b.input)     # Lab 8c gates this
                results.append({"type": "tool_result",
                                "tool_use_id": b.id, "content": str(out)})
        messages.append({"role": "user", "content": results})
    return "Stopped: hit step limit."

The loop honours the three Chapter 4 rules: append the assistant turn verbatim before results, give every tool_result the matching tool_use_id, and send all results in one user message. MAX_STEPS = 8 guarantees termination. On stop_reason == "end_turn" it returns the model's text answer; otherwise it runs each requested read-only tool via the registry and feeds results back. This needs an API key to run (ANTHROPIC_API_KEY + pip install anthropic) and uses the lesson's model, claude-opus-4-8.

Exercise 5 · Parse tool_use blocks into tool_result blocks (pure logic)Professional

Context: The loop's inner dispatch — turn tool_use blocks into tool_result blocks — is pure logic you can and should unit-test without ever calling the API.

Your task: Write a pure helper build_tool_results(content_blocks) that mirrors the engine's inner loop, and test it with fake blocks.

Requirements:

  • For each tool_use block, run the registered tool with its input
  • Emit one tool_result dict per tool_use, carrying the correct tool_use_id
  • Ignore non-tool_use blocks (e.g. text)
  • Test with a stand-in block object — no real Anthropic content needed
  • Assert one result per tool_use and that the id and type are correct; no API key

💡 Hint: Standing in a tiny fake block class lets you exercise the dispatch logic in milliseconds, isolated from the model.

Show solution
from agent.tools import TOOLS

class _Block:  # stand-in for an Anthropic content block, for testing
    def __init__(self, type, name=None, input=None, id=None, text=None):
        self.type, self.name, self.input, self.id, self.text = type, name, input, id, text

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

# test — no API key
def test_build_tool_results_one_per_tool_use():
    blocks = [
        _Block("text", text="let me look"),
        _Block("tool_use", name="kubectl_get_pods", input={"namespace": "staging"}, id="tu_1"),
    ]
    out = build_tool_results(blocks)
    assert len(out) == 1
    assert out[0]["tool_use_id"] == "tu_1"
    assert out[0]["type"] == "tool_result"

This isolates the loop's dispatch logic from the network. Text blocks are skipped; each tool_use becomes exactly one tool_result whose tool_use_id echoes the request's b.id — the invariant that avoids the 400 tool_use_id ... without tool_result error. Because it only touches the registry and the mock, it runs with no API key, letting you unit-test the agent's plumbing deterministically.

Exercise 6 · Guarantee no tool enters the registry untaggedIndustry scenario

Context: The Chapter 8c gate keys every decision on tool.risk, so a tool registered without a real risk class is a silent hole in the safety model. A one-line CI test closes it.

Your task: Write the registry guard test proving every tool has a real RiskClass, and explain why this test is a load-bearing safety control even though it needs no API key.

Requirements:

  • Loop the registry and assert each tool's risk is a real RiskClass
  • Fail with a message naming the offending tool
  • Also assert spec() exposes only name/description/input_schema (no risk, no run)
  • Explain that an untagged tool would slip past the gate
  • Runs every commit in CI at zero cost

💡 Hint: It's cheap precisely because it's pure — and cheap-but-load-bearing is exactly the kind of test you want gating every commit.

Show solution
from agent.tools import TOOLS
from agent.schemas import RiskClass

def test_every_tool_has_a_risk_class():          # no API key
    assert TOOLS, "registry is empty — tools not imported"
    for name, tool in TOOLS.items():
        assert isinstance(tool.risk, RiskClass), f"{name} missing risk"

def test_registry_specs_are_model_safe():        # no API key
    for name, tool in TOOLS.items():
        spec = tool.spec()
        assert set(spec) == {"name", "description", "input_schema"}
        assert "risk" not in spec and "run" not in spec   # never leaked to the model

The first test loops the whole registry and asserts each tool.risk really is a RiskClass — if someone registers a tool with risk="safe" (a plain string), it fails with … missing risk. That matters because the Lab 8c gate keys its allow/ask/block decision on this field; an untagged tool could otherwise slip past. The second test confirms spec() never exposes risk or run to the model. Both are instant and free, so CI runs them on every commit as the first line of defence.

✓ Checkpoint — Lab 8b complete when…

  • Your tool registry holds the read-only tools, each with a risk class.
  • The loop runs and the agent calls tools in a sensible order.
  • python cli.py "..." produces a correct diagnosis + an audit trail.
  • The no-API-key registry tests pass.

Knowledge check check yourself

✓ Knowledge check

Each Tool bundles name, description, input_schema, risk, and run, but spec() returns only the first three to the model. Why keep risk and run private to your program?

Show answer
Only name/description/input_schema are what the Anthropic tools=[...] parameter needs, so that's all the model sees. Keeping risk and run on your side means the safety gate can look up any tool's danger level and the loop controls execution — the model never learns the risk tag it might try to argue around, nor holds the real function.
✓ Knowledge check

A tool's description says 'List pods... Use FIRST when diagnosing.' Why does the wording of a tool description materially change agent behavior, and what happens if you make it vague?

Show answer
The description is the instruction manual the model reads to decide when to call a tool, so 'Use FIRST' nudges it to check pod health before anything else — that's how the observed pods -> logs -> deploys ordering emerges. Vague wording (just 'lists pods') makes the model less likely to call it first, so it may investigate in a worse order or skip the tool entirely.
© 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