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.
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
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
Strip 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.
The agentic loop, visualized intermediate
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 backarrow up to "Call model" and go around again. - The
loop backarrow (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
A tool is a name, a description, and a JSON schema for its inputs. The description is how the model decides when to use it — write it carefully.
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}"
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.
- The dictionary has three parts.
"name"is the tool's identifier ("get_weather"). The model uses this exact name when it wants the tool. "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."input_schema"describes the arguments in JSON schema — a standard way to say "this tool takes acity(a string) and an optionalunitthat must becelsiusorfahrenheit.""required": ["city"]means the model must supply a city.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.
Lab 4.2 · Write the loop intermediate
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.
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.
TOOLSis the list of tool descriptions you pass to the model.DISPATCHmaps each tool name to the real function to run — so when the model asks for"get_weather", you know which Python function to call.MAX_STEPS = 6is 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.- Inside the loop,
client.messages.create(...)sends the whole conversation (messages) and the tools. Thenif resp.stop_reason == "end_turn"checks the model's reply: if it's finished, we grab its text andreturn— that's the exit. - Otherwise the model asked for tools. First we append the model's turn verbatim (
resp.content) to history. Then the innerfor block in resp.contentloop finds eachtool_useblock, looks up the function withDISPATCH[block.name], and runs it withfn(**block.input). - Each result is packaged as a
tool_resultcarrying the sametool_use_idthe 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.
- Append the assistant turn verbatim (
resp.content) before adding results — thetool_useblock must be in history or the next call errors. - Every
tool_resultneeds the matchingtool_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
Give the agent a second tool and it will chain them (weather → then recommend clothing) or call several at once. Your loop already handles this — the for block in resp.content loop runs every requested tool. Add a tool and dispatch entry:
Continues tools.py from earlier in this lesson — run the previous block(s) first.
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}
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.
SEARCH_TOOLis a second tool dictionary, same shape asWEATHER_TOOL: aname, a when-to-usedescription, and aninput_schema(here it takes aquerystring).def search_docs(query)is its real function. It just callsretrieve_from_ch3(query)— the RAG retriever you built in Chapter 3. You've turned retrieval into a tool the model can choose to use.- In
agent.pyyou add both entries:TOOLS = [WEATHER_TOOL, SEARCH_TOOL]and a matchingDISPATCHline. 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.
Lab 4.4 · Safety — gates, caps, validation advanced
An agent takes actions. Some actions are irreversible (sending email, deleting data, spending money). Production agents need rails.
- Step cap — already in Lab 4.2. Never write an unbounded agent loop; it can spin forever and burn money.
- Human-in-the-loop gate for destructive tools.
Continues agent.py from earlier in this lesson — run the previous block(s) first.
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) - Validate inputs inside the tool — the model's arguments are untrusted. Check types, ranges, permissions before acting.
- 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.
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.
DESTRUCTIVE = {...}is a set of the tool names you consider dangerous. Membership in this set is the whole trigger for the gate.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.input("Approve? [y/N] ")stops and waits for you to type. If you don't type exactlyy, we don't run the tool — insteadoutbecomes a message saying it was denied. Only onydo we actually callDISPATCH[block.name](**block.input).- 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.
- ✅ 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
The loop in Lab 4.2 forgets everything when it returns. To make a conversational agent, persist messages across calls — and for long sessions, manage its growth.
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
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"
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.
class Agentbundles the conversation with the loop.__init__createsself.messages = []once. Because it lives on the object, it is not reset between calls — each new message is appended to the same growing list.chat(self, user_input)holds the familiar loop: add the user's message, thenfor _ in range(MAX_STEPS)call the model, append its reply, and eitherreturnonend_turnor run tools and loop again — exactly Lab 4.2, but reading and writingself.messages.- 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 inself.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.
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
| Question | Guidance |
|---|---|
| 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
| Pitfall | Fix |
|---|---|
| No step cap → infinite loop, runaway cost | Hard MAX_STEPS on every loop |
| Forgot to append the assistant turn | Append resp.content before tool results |
Mismatched / missing tool_use_id | Copy block.id into each tool_result |
| Split parallel results across messages | All tool_result blocks in one user message |
| Trusting tool arguments blindly | Validate inside the tool; gate destructive ones |
| Building an agent for a scriptable task | Use 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
TOOLSandDISPATCH - The agent chains them:
addfirst, thenmultiplyon the result - Each loop iteration is printed so the chaining is visible
- The final answer (100) comes back as an
end_turntext 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_docscalls the real Ch 3 retriever, not a stub- It is registered in both
TOOLSandDISPATCH - A docs-only question causes the model to issue a
search_docscall - 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_fileis treated as destructive and gated before execution- The run pauses and waits for an explicit
yto proceed - On denial, a
tool_resultexplaining 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.
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_schemaistype: objectwithaandbboth typednumberrequiredlists both inputs:["a", "b"]- The
descriptionsays when to reach for the tool, not just what it does - A runnable
assertproves 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"])
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:
DISPATCHmaps 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 acontentstring - The
tool_use_idon 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
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_useblock produces atool_resultwith itstool_use_id, all in one user message - A runaway model that always returns
tool_usestill 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
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_useblocks - Dispatch each block with
fn(**block.input)via a name→function map - Each result carries its own
tool_use_idcopied 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
contentlist)
💡 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"]))
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
DESTRUCTIVEset 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.
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 refusedTradeoffs. 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.
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
In the agentic loop, what are the two exit conditions, and why can the model never run a tool itself?
Show answer
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?