Functions, Modules & OOP
This is where code becomes a system. The capstone splits into modules (agent/, mock/); tools are dataclasses; risk levels are enums; the stateful agent and vector store are classes. Learn these and the whole capstone architecture reads cleanly.
- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.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
- Use
*args/**kwargsand unpacking — how tools get called. - Split code into modules and import between them — the capstone layout.
- Read type hints so function signatures tell you what goes in and out.
- Write classes for stateful things (the agent, the vector store).
- Use dataclasses and enums — the Tool registry and RiskClass.
1 · Functions, deeper intermediate
Here's a moment every agent-builder hits. You've written five tools — get_weather, search_docs, scale_service, and two more. Now the model says "run search_docs." Your first instinct is a wall of if name == "get_weather": ... elif name == "search_docs": ..., growing one branch per tool forever. Then someone shows you that in Python a function is just a value you can drop into a dict, and the whole ladder collapses into one line: DISPATCH[name](...). That small shift — functions are things, not just actions — is the mental unlock this section is built on.
The everyday analogy is a restaurant's bell system. The kitchen doesn't memorise which cook makes which dish; it has a row of labelled bells, and ringing "table 4's dessert" summons whoever is assigned to dessert. The label (a string) is separate from the worker (the function). Swap the pastry chef and nobody re-learns the bells. In Python the "bells" are dictionary keys and the "workers" are functions stored as values — which is exactly how an agent routes the tool the model picked without hard-coding every possibility.
The reason this works is that def get_weather(...) does two things: it builds a function object and binds the name get_weather to it. The name is just a label; the object lives independently. So fn = get_weather (no parentheses!) hands the same object a second label — nothing runs yet. This is the single most common beginner mistake here: writing fn = get_weather() with parentheses, which calls the function immediately and stores its return value instead of the function itself. Parentheses mean "do it now"; no parentheses means "hold onto the recipe for later." Keep that distinction sharp and the rest of the chapter falls into place.
The other quiet workhorse is returning several values at once. Python lets you return "the reply", ["chunk1", "chunk2"] — that comma builds a tuple — and the caller unpacks it in one line with text, sources = answer(q). It reads like the function handed back two labelled things, which is precisely how RAG's answer() gives you both the generated text and the source chunks it used, so you can show citations. From P1 you already know def, parameters, defaults, and return. Two more essentials: functions are values (you can store and pass them), and they can return multiple things (as a tuple).
dispatch[name](...) — instead of an ever-growing chain of if/elif.
Read it left to right: a name comes in on the left, it is looked up in the table in the middle, and the matching function runs on the right.
- The blue box on the left is the string the model chose — just text, like
"weather". It carries no behaviour on its own. - The white box in the middle is the dispatch dict: each row maps a name string to a function value. The first blue arrow ("look up") finds the row whose key matches the incoming name.
- The green box on the right is the payoff: the second arrow ("call") runs the function the table returned. Adding a new tool is just adding one more row — the caller's code never changes.
In short: a dispatch table separates the label (a key) from the worker (a function value), so an agent routes any tool with a single dispatch[name](...).
pythondef get_weather(city):
return f"sunny in {city}"
# a function is a value — store it, pass it around
fn = get_weather
fn("Paris") # 'sunny in Paris'
dispatch = {"weather": get_weather} # map a name -> a function
dispatch["weather"]("Oslo") # call it by name
# return several values as a tuple
def answer(q):
return "the reply", ["chunk1", "chunk2"]
text, sources = answer("q") # unpack the two return values
# a lambda: a tiny one-line function (no name)
run = lambda city, unit="C": f"18°{unit} in {city}"
This block shows a big idea: in Python a function is itself a value, just like a number or a string. You can put it in a variable, store it in a dict, and pass it around — then call it later. It also shows returning several results at once.
fn = get_weather— no parentheses, so we are not calling the function; we are giving it a second name.fn("Paris")then runs the same function.dispatch = {"weather": get_weather}stores the function under a name in a dictionary.dispatch["weather"]("Oslo")looks it up by that string and calls it. This "name → function" table is exactly how an agent picks which tool to run.return "the reply", ["chunk1", "chunk2"]returns two things as a tuple.text, sources = answer("q")unpacks them into two variables in one line.lambda city, unit="C": ...is a tiny nameless function written on one line — handy when a function is so small that giving it a fulldefwould be overkill.
What the output means: Nothing prints on its own here; each line evaluates to a value (e.g. fn("Paris") is 'sunny in Paris'). In a notebook the last expression of a cell shows automatically.
Try this: Add a second entry to dispatch, like "echo": lambda s: s, then call dispatch["echo"]("hi"). You just extended a dispatch table without touching any of the existing code — that is the whole point of storing functions by name.
DISPATCH = {"get_weather": get_weather} then DISPATCH[block.name](...) in Ch 4. Returning (text, hits) is how Ch 3's answer() works. lambda is how each capstone Tool's run= is defined.2 · *args, **kwargs & unpacking intermediate → essential
Picture the impossible situation this solves. An LLM returns a tool call: it wants scale run with {"name": "web", "replicas": 3}. Tomorrow it wants a different tool with completely different arguments — {"pod": "api-7", "lines": 50}. You cannot write your agent to know every tool's parameters in advance; the model decides those at runtime. So how do you call a function whose argument names you don't know until the moment you call it? The answer is a single operator, **, and once it clicks you'll see it as the hinge the whole tool-calling loop turns on.
Think of ** as an envelope-to-form translator. The model hands you a sealed envelope — a dict of name/value pairs. A function, meanwhile, is a paper form with labelled blanks: name, replicas, namespace. Writing scale(**args) tips the envelope out so each labelled slip drops into the matching blank on the form: {"name":"web","replicas":3} becomes scale(name="web", replicas=3). The keys have to match the blanks — that's the one rule. This is "unpacking": spreading a collection back out into individual arguments.
The mirror image is packing, and it's where **kwargs in a function definition comes in — and where the common confusion lives. People see ** in two places and assume it does one thing. It doesn't: at the call site ** spreads a dict out; in the parameter list **kwargs gathers leftover keyword arguments into a fresh dict. Same symbol, opposite direction — spreading versus collecting. Naming it kwargs is pure convention (short for "keyword arguments"); the ** is what actually matters, so **extras works identically. Getting the two directions straight is the whole skill.
Why does an agent care so much? Because it lets one line — tool.run(**block.input) — call any tool with whatever the model chose, and lets a wrapper like complete(messages, **kw) quietly forward extra options (model, temperature, whatever) down to the API without listing them all. The ** operator "spreads" a dict into keyword arguments. This is the exact mechanism the agent uses to call a tool with the arguments the model chose.
**args tips each name/value pair in the dict into the parameter with the matching name — which is why tool.run(**block.input) can call any tool without the agent knowing its parameters ahead of time.
Look at the two stacks and the arrows crossing between them: the dict on the left, the function's parameter slots on the right.
- The blue box on the left is the dict the model produced — each row is a
"key": valuepair. The keys are the important part; they are the labels that must match. - Each arrow is one pair being spread by
**into the parameter of the same name on the right."name": "web"lands in thenameslot;"replicas": 3lands inreplicas. - The green box on the right is the resulting call — exactly as if you had typed
scale(name="web", replicas=3)by hand. The single rule: a key with no matching parameter is an error.
In short: at a call site ** pours a dict into named arguments by matching keys to parameter names — the hinge the whole tool-calling loop turns on.
pythondef scale(name, replicas, namespace="staging"):
return f"{namespace}/{name} -> {replicas}"
args = {"name": "web", "replicas": 3}
scale(**args) # same as scale(name="web", replicas=3)
# a function that accepts any keyword args
def complete(messages, **kw): # kw is a dict of the extras
print(kw) # {'model': 'claude-opus-4-8', 'effort': 'high'}
complete([], model="claude-opus-4-8", effort="high")
The ** operator "spreads" a dictionary into named arguments of a function. This is the single trick that lets an agent call any tool with whatever arguments the model chose — without knowing them in advance.
args = {"name": "web", "replicas": 3}is a plain dict of argument values.scale(**args)is the magic: the**unpacks the dict so it becomesscale(name="web", replicas=3). The dict keys must match the parameter names.def complete(messages, **kw):does the reverse — the**kwcollects any extra keyword arguments the caller passes into a dict namedkw. So callingcomplete([], model="claude-opus-4-8", effort="high")makeskw == {'model': 'claude-opus-4-8', 'effort': 'high'}.
What the output means: print(kw) shows the collected extras as a dictionary: {'model': 'claude-opus-4-8', 'effort': 'high'}.
Try this: Add another argument, e.g. complete([], model="x", temperature=0.2), and watch temperature appear in the printed dict. This is why the agent's tool.run(**block.input) works no matter what fields a tool needs.
scale with {name:'web', replicas:3}", the agent runs tool.run(**block.input) — the ** turns that dict into the function's arguments. One line, and any tool can be called generically.tool.run(**block.input) is the heart of tool execution in Ch 4 and the capstone engine. **kw pass-through is in the Ch 1 client wrapper and Ch 6 traced_call.3 · Modules & imports essential
Every project starts the same way and ends the same way. Day one, everything lives in one heroic agent.py — the tools, the prompts, the loop, the safety rules, all of it. It feels fast. Then it hits 800 lines, two changes in different places start colliding, and you can't find the function you wrote last week. The fix isn't discipline, it's modules: you cut the monolith into named files, and suddenly the capstone reads as agent/ and mock/ folders whose names tell you where things live. This section is about how one file borrows from another.
The analogy is a kitchen with labelled drawers. You don't keep every utensil in one giant bin; you have a drawer for knives, one for spoons, one for spices. A module is a drawer, and import is you opening the right one to grab exactly the tool you need. import json is "open the whole json drawer" — you then reach in with a dot, json.load(f), and it's always obvious where load came from. from anthropic import Anthropic is "just take the one thing out" so you can use Anthropic bare. Both are fine; the dotted form keeps origins visible, the from form keeps call sites short.
A package is the next step up: a folder of related modules that Python treats as one importable unit, marked (historically) by an __init__.py file sitting inside it. That's why from agent.schemas import RiskClass reads as a little filesystem path — "the file schemas.py, inside the folder agent/." The dots are folder separators for code.
Now the mistake that will cost you an hour if nobody warns you: ModuleNotFoundError. Nine times out of ten it does not mean your code is broken. It means Python was launched from the wrong directory, so it's looking for agent/ in a place where agent/ isn't — or the folder is missing its __init__.py and so isn't a package Python will import from. Before you doubt a single line of logic, check where you ran python from and whether the __init__.py exists. A module is just a .py file. import lets one file use another's functions/classes. A folder with an __init__.py is a package.
__init__.py in each folder is what makes agent/ and mock/ importable, so from agent.schemas import RiskClass reads as a filesystem path — folder, then file, then name.
Read top-down: the project root fans out into two package folders, and each folder holds its individual .py module files.
- The purple box on top is the project root — the directory you launch
pythonfrom. The two arrows split it into the agent/ and mock/ folders. - Inside each white folder box, the small blue boxes are the module files. The
__init__.pyin each is the marker that promotes a plain folder into an importable package — without it,ModuleNotFoundError. - The dashed arrow between the folders is the payoff:
loop.pyinsideagent/doesfrom mock import cluster, so one package borrows another's code — exactly how the capstone is wired.
In short: folders + __init__.py = packages; dotted imports like agent.schemas are just paths through those folders.
Requires: pip install anthropic pydantic
python# import a whole module, use dotted names
import json
json.load(f)
# import specific names from a module
from anthropic import Anthropic
from pydantic import BaseModel, Field
# import from your OWN package (the capstone does this)
from agent.schemas import RiskClass # from agent/schemas.py
from mock import cluster, actions # from mock/cluster.py, mock/actions.py
A module is just a .py file, and import lets one file borrow the functions and classes defined in another. This is how the capstone is split into agent/ and mock/ folders instead of one giant file.
import jsonbrings in the whole module; you then reach inside it with a dot:json.load(f). The name stays prefixed, so it's obvious whereloadcame from.from anthropic import Anthropicpulls out just one name so you can writeAnthropicdirectly instead ofanthropic.Anthropic.from agent.schemas import RiskClassimports from your own package. The dotted pathagent.schemasmeans "the fileschemas.pyinside the folderagent/". A folder becomes an importable package when it contains an__init__.pyfile.
Try this: If you ever hit ModuleNotFoundError, it almost always means you ran Python from the wrong folder, or a package folder is missing its __init__.py. Check both before assuming the code is broken.
ModuleNotFoundError almost always means you're running from the wrong directory, or a folder is missing its __init__.py. The capstone's troubleshooting tables (Lab 8a) cover this exactly — cd into the project root and ensure __init__.py exists.agent/ imports from mock/, the loop imports from agent.policy import evaluate, tests import both (Lab 8a layout). The shared llmkit.py is a module every lab imports.4 · Type hints intermediate
You will inherit code you didn't write — from a teammate, from an SDK, from yourself six months ago. You open a function called process(data, opts) and stare. What is data? A list? A dict? A string of JSON? What does it hand back? The only way to know is to read the entire body, or run it and see what explodes. Now imagine the signature had read process(data: list[str], opts: dict[str, int]) -> Sentiment. You'd know the contract at a glance without reading a line of the body. That glance is what type hints buy you, and it's why this course sprinkles them everywhere.
Think of a type hint as the label on a shipping box: "Fragile — glassware," "This end up." The label doesn't physically stop you from dropping the box — and this is the part that surprises people — Python does not enforce type hints at runtime. You can annotate lines: int and still pass a string; Python shrugs and runs it. The hints are for humans reading the code and for tools — your editor's autocomplete, a type-checker like mypy, and especially Pydantic, which reads the hints and actually does validate against them. So the honest framing is: hints are documentation that some tools choose to enforce.
That non-enforcement is the classic beginner trap. Someone annotates carefully, sees no error when they pass the wrong type, and concludes "type hints do nothing." They're half right — hints do nothing on their own at runtime; their whole payoff comes from the tooling that reads them. A few pieces of vocabulary you'll lean on: list[str] is "a list of strings," dict[str, int] is "string keys, integer values," Optional[str] means "a string or None" (for a value that might be missing), and Literal["low", "high"] means the value must be exactly one of those spelled-out choices.
That last one, Literal, is quietly the star of an AI course: it's how you pin an LLM's structured output to a closed set of allowed values so a stray "kinda high" can't slip through and corrupt downstream logic. Type hints annotate what a function takes and returns. Python doesn't enforce them, but they make code readable and power tools like Pydantic. This course uses them throughout.
-> str is the output — readable at a glance — but only your editor, a type-checker, or Pydantic acts on them; the interpreter runs regardless.
Read the top row left-to-right as a function's contract, then drop to the bottom row to see who actually acts on it.
- The blue box (left) is the annotated inputs —
pod: str,lines: int— and the white box (middle) is the whole signature reading as one labelled contract you grasp without reading the body. - The green box (right) is the output the
->arrow declares: this function returns astr. - The bottom row is the twist: the red box shows Python itself ignores the hints at runtime, while the green box shows the tools (editor autocomplete, mypy, Pydantic) that read and enforce them. That split is why a wrong type "runs fine" yet still gets flagged by your tooling.
In short: types before the -> are what goes in, the type after is what comes out — pure documentation until a tool chooses to check it.
pythonfrom typing import Optional, Literal
def get_logs(pod: str, lines: int = 20) -> str:
# pod is a str, lines is an int (default 20), returns a str
return "..."
names: list[str] = ["a", "b"] # a list of strings
counts: dict[str, int] = {"a": 1} # dict: str keys, int values
ref: Optional[str] = None # str OR None
level: Literal["low", "high"] = "low" # only these exact values
Type hints are labels that say what kind of value each variable or argument holds. Python does not enforce them at runtime — they are documentation for humans and fuel for tools like editors and Pydantic. They make a function's contract readable at a glance.
def get_logs(pod: str, lines: int = 20) -> str:reads as:podshould be a string,linesan integer (defaulting to 20), and the function returns a string (that's what-> strmeans).names: list[str]means "a list whose items are strings";counts: dict[str, int]means "a dict with string keys and integer values".Optional[str]means "a string orNone" — use it when a value might be missing.Literal["low", "high"]means the value must be exactly one of those spelled-out choices, nothing else.
Try this: Change level to "medium". Python won't complain when you run it (hints aren't enforced), but an editor or Pydantic model using that Literal would flag it — which is exactly how the course keeps structured LLM output valid.
Literal[...] is what makes structured output safe — priority: Literal["low","medium","high"] in the Ch 2 Ticket and fix_risk: RiskClass in the capstone Diagnosis. Return hints like -> Sentiment | None appear in the Ch 2 classifier.5 · Classes (OOP) intermediate
Try building a chatbot with only functions and you'll feel the pain immediately. Each turn needs the whole conversation so far, so you end up threading a history list through every single call — respond(text, history), then respond(next_text, history), carrying that list by hand like a hot plate you can't put down. Lose track of it once and the bot forgets everything. A class is the moment you get to set the plate down: the conversation lives inside the object, and the object remembers it for you between calls. That's what "state" means, and it's why the agent is a class.
The analogy that makes classes click is a cookie cutter and its cookies. The class is the cutter — the shape, the blueprint. Each object you stamp out (an instance) is a separate cookie made from that shape but living its own life. Two Agent() instances share the design (same methods, same structure) but each has its own messages list, so one user's conversation never bleeds into another's. A class bundles two things the cutter analogy captures: the data each cookie carries (its state) and the behaviour the shape defines (its methods).
Two words trip up nearly every newcomer. __init__ is the constructor — it runs automatically the instant you write Agent(), and its only job is to set up the starting state (self.messages = []). You never call it by hand; creating the object calls it for you. And self is simply "this particular cookie" — the specific instance a method is working on. When you write self.messages.append(...), you're saying "add to this instance's own list." The common mistake is forgetting self. and writing a bare messages = [] inside a method: that makes a throwaway local variable that vanishes when the method returns, and the object remembers nothing. If it should survive between calls, it lives on self.
The rule of thumb for when to reach for a class: use one when a thing has state that changes over time and behaviour that acts on that state. A one-shot calculation is a function; a conversation, a growing index, a connection you open and reuse — those are objects. A class bundles data + behavior. Use one when something has state that changes over time — like an agent that remembers a conversation, or a vector store that holds indexed chunks.
messages list created inside __init__ — which is why one user's conversation never bleeds into another's.
Read top to bottom: the class at the top is the blueprint; the two boxes below are separate objects stamped from it.
- The purple box at the top is the
class Agent— the shared design. It holds the methods (__init__,chat) but no per-conversation data of its own. - Each arrow is a call to
Agent()that stamps out a new instance. The two white boxes are those instances —aandb. - The payoff is the
self.messagesline inside each white box: they are different lists. Because__init__runs per instance and creates the list onself,a's history andb's history stay independent.
In short: the class supplies shared behaviour; each instance supplies its own state — that separation is exactly what "stateful object" means.
pythonclass Agent:
def __init__(self): # runs when you create one
self.messages = [] # state: this instance's history
def chat(self, text): # a method — self is "this instance"
self.messages.append({"role": "user", "content": text})
return f"({len(self.messages)} messages so far)"
a = Agent() # create an instance (__init__ runs)
a.chat("hi") # call a method
a.chat("again") # state persists on 'a'
A class is a blueprint that bundles data + behaviour together. You use one when a thing has state that changes over time — like an agent that remembers its conversation. Each object you build from the class (an instance) keeps its own private copy of that state.
def __init__(self):is the constructor — it runs automatically the moment you create an instance, and its job is to set up starting state. Here it makes an emptyself.messages = [].selfmeans "this particular instance". Storing data onselfis what lets oneAgentremember things independently of any otherAgent.def chat(self, text):is a method — a function that belongs to the class and receivesselfso it can read and change that instance's state.a = Agent()builds an instance (running__init__). Each call toa.chat(...)appends to a's own list, so the count grows across calls.
What the output means: a.chat("again") returns "(2 messages so far)" because the two messages accumulated on this one instance — the state persisted between calls.
Try this: Create a second agent b = Agent() and call b.chat("hi"). Its count starts at 1, proving each instance has its own messages list.
__init__ is the setup method; self refers to the specific instance so each object keeps its own state.
class Agent that remembers turns is Ch 4 Lab 4.5 and the capstone. class VectorStore holds the embedded chunks and has a .search() method in Ch 3 Lab 3.2. class Hybrid wraps BM25 in Lab 3.5.6 · Dataclasses intermediate
Here's the tedium a dataclass abolishes. You want a small record — a Tool with a name, a description, a risk level, and a function to run. Written by hand, that's a class where you type every field name three times: once in __init__'s parameters, once as self.name = name, and again for the next field, and the next. Then, when you print() it to debug, you get the useless <Tool object at 0x10f3a2b80> and have to hand-write a __repr__ too. It's pure boilerplate, and boilerplate is where typos hide. The @dataclass decorator writes all of it for you.
Think of it as a form template. Instead of drafting a new paper form from scratch — drawing every box and label — you tick "I need fields: name, description, risk, run" and the office prints a ready-to-fill form with the boxes, the labelling, and a tidy summary printout already done. In code, you just list the fields with type hints under @dataclass, and Python auto-generates the constructor (so Tool("get_pods", "list pods", "read_only", fn) just works, in field order) plus a readable __repr__ and sensible equality.
The one gotcha that bites everyone eventually: fields with defaults must come after fields without them. Just like function arguments, you can't have a required field sitting after an optional one — name: str then enabled: bool = True is fine, but flip them and Python raises an error at class-definition time. (There's a subtler trap too, inherited from section 8: never use a mutable default like [] directly; dataclasses make you use field(default_factory=list) instead, for exactly the shared-state reason you'll meet shortly.)
Why does the course lean on dataclasses so hard? Because a tool is fundamentally a record that happens to carry a callable — its behaviour (the run function) sits right next to its metadata (name, risk). That co-location is what lets the safety gate look up a tool and instantly know its risk before deciding whether to run it. A dataclass is a class that's mostly just fields — Python writes the boilerplate __init__ for you. Perfect for a "record" like a Tool.
@dataclass decorator writes the __init__, a readable __repr__, and equality — the exact tedium you'd otherwise type by hand three times per field.
Read left-to-right as a transform: your short field list goes in on the left, the decorator sits in the middle, and generated methods come out on the right.
- The blue box (left) is all you write — four fields with type hints, each named exactly once.
- The amber box (middle) is the
@dataclassdecorator, which rewrites the class as it is defined. - The green box (right) is what Python hands back for free: the
__init__constructor (in field order), a readable__repr__, and an__eq__— none of which you had to type.
In short: a dataclass turns a list of typed fields into a full record class, so a Tool is just its fields plus a free constructor and printout.
pythonfrom dataclasses import dataclass
from typing import Callable
@dataclass
class Tool:
name: str
description: str
risk: str
run: Callable # a function is a valid field!
t = Tool("kubectl_get", "list pods", "read_only", lambda: "pods...")
t.name # 'kubectl_get'
t.run() # 'pods...'
A dataclass is a shortcut for a class that mostly just holds a few fields. You list the fields with type hints and Python auto-writes the tedious __init__ (and a readable printout) for you. It's perfect for a plain "record" — like describing one tool.
@dataclassis a decorator: a line above the class that rewrites it, adding the constructor automatically from the fields below.name: str,description: str,risk: str,run: Callabledeclare four fields.Callablemeans "a function" — so a field can literally hold a function you'll call later.t = Tool("kubectl_get", "list pods", "read_only", lambda: "pods...")creates one in the order the fields were declared — no__init__written by hand.t.run()calls the stored function. Bundling the behaviour (run) together with its metadata (name, risk) in one object is what makes a tool registry tidy.
What the output means: t.name is 'kubectl_get' and t.run() returns 'pods...' — the lambda we stored gets executed on demand.
Try this: Add a field enabled: bool = True after run (fields with defaults must come last) and create a tool without passing it. The dataclass fills in the default for you.
@dataclass class Tool — name + description + risk + the run function, all in one record. Bundling the risk class with the tool is what lets the safety gate look it up.7 · Enums intermediate
Somewhere in every codebase a bug like this is waiting. A tool's risk is stored as the string "read_only". The safety check reads if risk == "readonly": — no underscore. It compiles, it runs, it never matches, and a write action that should have been blocked sails straight through. No error, no crash, just a silent hole in your safety gate discovered the hard way. The whole class of bug comes from using free-text strings for values that should be a fixed, known set. An enum closes that hole.
An enum is like the gear selector in a car: P, R, N, D. There is no "kinda-drive" or "drive-ish" — the lever physically only stops at defined positions. You reference RiskClass.READ_ONLY, a real named thing the editor autocompletes and Python can verify, instead of a loose string you might fat-finger. Misspell the member name and you get an immediate, loud AttributeError at the point of the mistake — not a quiet wrong-answer three functions downstream. That's the trade: enums move typos from silent runtime bugs to obvious, early failures.
Two flavours matter here, and mixing them up is the common confusion. A plain Enum gives you identity — members are distinct, comparable with ==, but have no order (asking A < B raises a TypeError). An IntEnum also makes each member an integer, so members can be ordered and compared with < and >. The rule: use a plain Enum when you just need distinct labels ("is this the irreversible one?"), and an IntEnum when the values form a ranking you'll compare ("is this rung more powerful than that one?"). Inheriting from str as well (class RiskClass(str, Enum)) is a handy extra: each member also behaves like its string value, which keeps JSON serialization and equality clean.
In this course the two enums RiskClass and Rung are the literal backbone of the capstone's safety model — the gate is a table indexed _MATRIX[rung][risk], enums used as dict keys. An enum is a fixed set of named values. Use one when a value must be one of a known list — like risk levels. Typo-proof and readable.
"readonly" slips through silently; an enum stops only at defined members, turning a typo into an immediate AttributeError instead of a hidden safety hole.
Compare the two halves split by the dashed line: loose strings on the left, a fixed enum on the right.
- On the left (red), the value is a bare string. The correct
"read_only"sits on top, but nothing prevents the typo"readonly"or the nonsense"kinda_high"— Python accepts them all and the mistake only surfaces later, wrongly. - On the right (green),
RiskClassis the parent and its only members areREAD_ONLYandIRREVERSIBLE. The arrows show the value can be only one of those two. - The payoff is the contrast in failure mode: a bad string fails silently downstream, while a mistyped enum member fails loudly and immediately at the point you wrote it.
In short: a string can be anything (and typos hide); an enum can be only its named members, so wrong values fail early and loudly.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
risk = _Any()pythonfrom enum import Enum, IntEnum
class RiskClass(str, Enum):
READ_ONLY = "read_only"
IRREVERSIBLE = "irreversible"
RiskClass.READ_ONLY # the member
RiskClass.READ_ONLY.value # 'read_only'
risk == RiskClass.IRREVERSIBLE # safe comparison — no typos possible
# IntEnum members are ordered — useful for "rungs"
class Rung(IntEnum):
OBSERVE = 1
ACT = 3
Rung.ACT > Rung.OBSERVE # True
An enum (enumeration) is a fixed, named set of allowed values. Reach for one when a value must be one of a known short list — like a risk level. It's typo-proof: you refer to RiskClass.READ_ONLY, not the loose string "read_only" that's easy to misspell.
class RiskClass(str, Enum):defines the set. Inheriting fromstrtoo means each member also behaves like its string value, which is handy for JSON and comparisons.READ_ONLY = "read_only"defines one member.RiskClass.READ_ONLYis the member object;RiskClass.READ_ONLY.valueis its underlying string'read_only'.risk == RiskClass.IRREVERSIBLEis a safe comparison — if you fat-finger the name, Python raises an error instead of silently comparing against a wrong string.class Rung(IntEnum):makes members that are also integers, so they can be ordered.Rung.ACT > Rung.OBSERVEisTruebecause 3 > 1 — useful for ranking permission levels.
What the output means: Rung.ACT > Rung.OBSERVE evaluates to True; enums as ordered rungs let the safety gate compare "how powerful is this action" numerically.
Try this: Compare two RiskClass members with < — you'll get a TypeError, because a plain Enum has no order. That's exactly why the ordered levels use IntEnum instead.
RiskClass and Rung are the two enums the entire capstone safety model is built on (Lab 8a, Lab 8c). The gate is literally _MATRIX[rung][risk] — enums as dict keys. The "irreversible never allowed" safety test compares enum members.8 · The mutable-default trap must-know gotcha
This is the bug that makes you doubt your sanity. You write def add_message(msg, history=[]), test it once, get ['a'] — perfect. You ship it. Days later, users report messages from other people's conversations appearing in their chat. You re-read the function ten times; the logic is obviously correct. Nothing looks wrong. The horror of this trap is that the code is readable and reasonable and still broken, and it's the number-one confusing bug for people building stateful agents. Learn it once here and you'll spot it forever.
The cause hides in a Python rule that's actually consistent, just surprising: a default argument is evaluated exactly once, when the function is defined — not fresh on each call. So that [] is created a single time and quietly reused by every call that doesn't supply its own list. The mental model most people carry ("a new empty list each call") is simply wrong; the truth is "one list, born at definition, shared forever."
An analogy: imagine a form that's supposed to give each visitor a blank notepad, but the office only ever bought one notepad and hands that same dog-eared pad to everyone. Visitor A jots "a." Visitor B gets the pad — and sees A's note still on it, then adds "b," so it now reads "a, b." Nobody erased anything between visitors because there was never a second pad. For a value type like a number the trap doesn't bite (you can't scribble on the number 5), which is exactly why beginners get away with it until the first list or dict default.
The fix is a fixed idiom worth memorising: default the argument to None, then create the real object inside the function — if history is None: history = []. Now every call that needs one gets a genuinely fresh list. This is the same reason the course's agent sets self.messages = [] inside __init__ (which runs per instance) rather than as a shared class-level default — otherwise every agent would scribble on the same notepad. One Python quirk bites nearly everyone building agents: a mutable default argument (like [] or {}) is created once and shared across all calls. This causes conversation histories to leak between agent instances — a nasty, confusing bug.
[] default is built once at definition and reused, so calls accumulate; defaulting to None and creating the list inside gives every call its own — the exact reason the agent sets self.messages = [] inside __init__.
Compare the two halves split by the dashed line: the red (wrong) side on the left, the green (right) side on the right. On both sides, two calls come in from the left.
- On the left (red), both call arrows point at the same red box — one list born once at definition time. Call 2 finds call 1's
'a'still sitting there, so the list grows to['a', 'b']: the leak. - On the right (green), each call arrow points at its own green box. Because the list is created inside the function only when
history is None, call 1 gets['a']and call 2 gets a clean['b']. - The payoff is the contrast: same inputs, but the shared default entangles the calls while the None-sentinel keeps them independent.
In short: a mutable default is one object reused forever; default to None and build the real object inside so every call starts fresh.
python# ✗ WRONG — the default list is shared across every call!
def add_message(msg, history=[]): # created ONCE, reused
history.append(msg)
return history
add_message("a") # ['a']
add_message("b") # ['a', 'b'] ← 'a' leaked in! not a fresh list
# ✓ RIGHT — use None as the default, create inside
def add_message(msg, history=None):
if history is None:
history = [] # a fresh list every call
history.append(msg)
return history
This is one of Python's most famous traps, and it bites people building agents. A default argument is created only once — when the function is first defined — not fresh on each call. So a mutable default like [] is secretly shared by every call that relies on it.
def add_message(msg, history=[]):looks innocent, but that[]is built a single time. Every call that doesn't pass its ownhistoryreuses the same list.- So
add_message("a")gives['a'], but the next calladd_message("b")gives['a', 'b']— the earlier'a'leaked in because it was never a fresh list. - The fix: use
history=Noneas the default, thenif history is None: history = []inside the function. Now a brand-new list is created on every call that needs one.
What the output means: The wrong version accumulates across calls (['a'] then ['a', 'b']); the fixed version returns a clean ['a'] then ['b'].
Try this: Run the wrong version three times in a row and watch the list keep growing. This is the exact reason the course's agent sets self.messages = [] inside __init__ (runs per instance) rather than as a shared default.
Agent() would share one messages list — one user's conversation bleeding into another's. That's why the course's stateful agent creates self.messages = [] inside __init__ (which runs per instance), never as a class-level default.9 · *args and packing/unpacking, in full intermediate
Section 2 gave you half a superpower; here's the other half. You met ** for keyword arguments — the dict-to-form trick. But the model might ask you to run three tools in one turn, and your agent has to bundle all three results back into a single user message. Or you're building a request from a shared base config plus a couple of overrides. These are the moments where knowing both the * (single-star) and ** (double-star) forms, in both directions, turns clumsy code into one clean line. This section completes the picture.
Hold two axes in your head and everything sorts itself out. The first axis is one star vs two: * deals with positional values (order matters, no names — like items in a list), while ** deals with keyword values (name=value pairs — like a dict). The second axis is direction: in a function's parameter list, a star packs loose arguments into a collection; at a call site, a star unpacks a collection back out into loose arguments. Four combinations, but really just two ideas crossed together.
The everyday image is a suitcase. Packing (def combine(*blocks)) is zipping many loose items into one bag — combine("a","b","c") arrives inside the function as the tuple ("a","b","c"). Unpacking (func(*mylist)) is opening the bag at the destination so each item comes out as its own separate argument again. The confusion to avoid: a star in a def line is the "close the suitcase" side; a star at the call is the "open the suitcase" side. Same symbol, opposite ends of the trip.
One more move you'll use constantly: {**base, "max_tokens": 4096} spreads an existing dict into a new one and overrides a key — the clean, non-destructive way to make a request variant. Crucially it copies, so the original base is never mutated (a fact worth verifying yourself, and a common source of "why did my shared config change?" bugs when people mutate instead of spread). P2 showed **kwargs for calling tools. Here's the complete picture — *args collects positional arguments, and */** unpack collections into calls. This is how the agent passes "all tool results in one message" and how variadic helpers work.
Read it as a grid: the row tells you how many stars, the column tells you which direction. Any real use of * or ** is one of these four cells.
- The rows split by star count: the top row is single
*(positional values, order matters); the bottom row is double**(keywordname=valuepairs). - The columns split by direction: the left column is a star in a
defline, which packs loose arguments into a collection; the right column is a star at a call site, which unpacks a collection back out into arguments. - The payoff is the diagonal insight in the footer: it is always the same symbol — the location (definition vs call) is what flips it between collecting and spreading.
In short: star = positional, double-star = keyword; in a def it collects, at a call it spreads. Fix those two axes and all four uses read at a glance.
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
python# *args: accept any number of positional arguments (collected into a tuple)
def combine(*blocks):
return list(blocks)
combine("a", "b", "c") # ['a', 'b', 'c']
# unpack a list INTO positional args with *
results = [{"type":"tool_result"}, {"type":"tool_result"}]
# NewUserMessage(*results) would pass each result as a separate argument
# merge dicts with ** (build a request from a base + overrides)
base = {"model": "claude-opus-4-8", "max_tokens": 1024}
call = {**base, "max_tokens": 4096} # override just one key
# {'model': 'claude-opus-4-8', 'max_tokens': 4096}
# combine: forward extra kwargs to another function
def complete(messages, **kw):
return client.messages.create(messages=messages, **kw) # pass-through
This rounds out packing and unpacking. * deals with positional values (order matters, no names), and ** deals with keyword values (name=value). Collecting is "packing"; spreading them back into a call is "unpacking".
def combine(*blocks):— the*packs any number of positional arguments into a single tuple namedblocks.combine("a","b","c")makesblocks == ("a","b","c").NewUserMessage(*results)would unpack a list back out: each item becomes a separate positional argument — how the agent sends several tool results in one message.{**base, "max_tokens": 4096}builds a new dict frombaseand then overrides one key. This is the clean way to make a request variant without mutating the original.def complete(messages, **kw):thenclient.messages.create(messages=messages, **kw)(orclient.responses.create(input=..., **kw)on OpenAI) shows pass-through: collect extra kwargs, then forward them onward unchanged.
What the output means: combine("a","b","c") is ['a','b','c']; the merged dict is {'model': 'claude-opus-4-8', 'max_tokens': 4096} — same model, bigger token budget.
Try this: Build {**base, "model": "claude-haiku"} and confirm base itself is unchanged afterward. Spreading always makes a copy, which is why it's safe for overrides.
NewUserMessage(*tool_results) sends all results in one turn (Ch 4). {**base, ...} merging builds request variants. **kw pass-through is the Ch 1 wrapper and Ch 6 traced_call.10 · Inheritance & super() intermediate → advanced
You'll bump into inheritance the day you use somebody else's SDK. The Anthropic tools docs hand you a class called BetaAbstractMemoryTool and say "subclass this and fill in view, create, and friends." Or you want a custom error and Python says "subclass Exception." You didn't write those parents, and you don't want to — you want to build on them, keeping everything they already do and adding your own twist. That "build on top of, don't rewrite" move is inheritance, and it's why the concept matters even if you never design a class hierarchy yourself.
The cleanest way to feel it is the phrase "is-a." A VectorStore is a Store that also knows how to embed things. A savings account is a bank account with interest. When the child genuinely is a kind of the parent, inheritance fits; when it merely uses the other thing (a car has an engine, it isn't one), you'd reach for plain composition instead. Getting that "is-a vs has-a" instinct right is most of good object design, and mixing them up is the classic beginner over-reach — inheriting just to borrow a couple of methods you could have simply called.
The one piece of machinery to understand is super(). When a child overrides a parent's method — writes its own __init__ or its own add — it usually still wants the parent's version to run as well, not be thrown away. super().__init__(name) means "run the parent's setup first," so the shared state (self.name, self.items = []) still gets created before the child adds its own. Then the child layers on its extras. The single most common inheritance bug is forgetting that super().__init__() call in the child's constructor: the parent's setup never runs, and the first time you touch an attribute the parent was supposed to create — self.items — you get an AttributeError that looks baffling until you remember the parent never got to initialise.
The payoff is "reuse the parent, add specifics": call super().add(x) to get the parent's behaviour, then extend it. A subclass reuses and extends a parent class. You'll meet this when the SDK gives you a base class to subclass — like the memory-tool handler or a custom exception.
Store defines; where it overrides a method, super() runs the parent's version first (setting up self.items) so the child only has to add its own twist — forget that call and self.items never gets created.
Read top to bottom: the parent class sits on top, the child that inherits from it sits below, and the amber box shows what super() reaches back to.
- The white box on top is the parent
Storewith its own__init__andadd. The solid down arrow labelled "is-a" meansVectorStoreinherits all of it automatically. - The purple box is the child. Its methods override the parent's, but each one calls
super()— shown reaching into the amber box — to run the parent's version first, then layer on the child's extra (self.model, the embed print). - The payoff: because
super().__init__runs the parent setup, the sharedself.itemslist exists before the child touches it. Skip that call and the first use ofself.itemsraisesAttributeError.
In short: inheritance gives the child the parent's methods for free; super() lets an overriding method reuse the parent's behaviour instead of replacing it.
pythonclass Store:
def __init__(self, name):
self.name = name
self.items = []
def add(self, x):
self.items.append(x)
# VectorStore IS-A Store, plus embedding behavior
class VectorStore(Store):
def __init__(self, name, model):
super().__init__(name) # run the parent's __init__ first
self.model = model # then add our own state
def add(self, x):
super().add(x) # reuse parent behavior…
print(f"embedded & stored {x}") # …and extend it
vs = VectorStore("docs", "minilm")
vs.add("chunk1") # uses both
Inheritance lets one class build on another instead of copying code. The child (subclass) automatically gets the parent's methods, and can add new ones or tweak existing ones. Read it as an "is-a" relationship: a VectorStore is a Store with extra powers.
class VectorStore(Store):— the(Store)means "inherit everything fromStore". Without writing them,VectorStorealready hasStore's attributes and methods.super().__init__(name)calls the parent's constructor first, so the shared setup (self.name,self.items = []) still happens. Then the child adds its own state:self.model = model.def add(self, x):overrides the parent'sadd. Inside,super().add(x)reuses the parent's version (append to the list), and then the child extends it with an extraprint.
What the output means: vs.add("chunk1") both stores the item (parent behaviour) and prints embedded & stored chunk1 (the child's addition) — reuse plus extension.
Try this: Comment out the super().__init__(name) line and create a VectorStore. You'll get an error the first time self.items is used, because the parent's setup never ran. That's what super() was doing for you.
BetaAbstractMemoryTool, referenced in the tool docs). Custom exceptions (P4) subclass Exception. The pattern of "reuse the parent, add specifics" is how you'd extend the capstone's VectorStore or Agent.11 · __repr__, @property, static & class methods intermediate
Every developer has had this exact frustrating debugging session. You've got a list of diagnosis objects, you print() it to see what's inside, and you get back a row of <Diagnosis object at 0x10f3a2b80>. Useless. You have no idea which is which. So you start bolting on print(d.cause, d.confidence) everywhere, cluttering your code just to see your data. That papercut — objects that won't tell you who they are — is exactly what the small class features in this section fix. They're not glamorous, but they're the difference between an object that's pleasant to live with and one that fights you.
Start with __repr__, the most immediately useful. It's a dunder method — "double underscore," Python's naming for hooks it calls on your behalf at special moments. Define __repr__ and you decide what shows when the object is printed or inspected: instead of the hex-address gibberish you get a readable Diagnosis(cause='db auth', conf=0.91). Think of it as your object's name tag at a conference — without one, everybody's a faceless silhouette; with one, you can tell them apart at a glance.
Next, @property — and here's the subtlety people miss. A property is a method dressed up to look like an attribute. You define is_confident as a function, but you access it as d.is_confident with no parentheses, and it computes fresh each time. The trap is exactly that missing-parens confusion, in both directions: add () to a property and you get a TypeError (you're trying to call a plain value); forget () on a real method and you get back the function object instead of its result. The rule of thumb: use a property for a lightweight derived value ("is this confident enough?") that feels like data, and a normal method for an action or anything expensive.
Finally, @staticmethod marks a function that lives under a class for tidy grouping but takes no self because it doesn't touch instance state — commonly a factory like from_dict(d) that builds an instance from raw data. Four class features that make your objects pleasant to use and debug.
__repr__ controls how the object prints, a @property is a computed value you read without parentheses, and a @staticmethod is a related function grouped under the class that takes no self.
Start at the class on top and follow each arrow down to one of the three helpers it carries; the label under each shows how you actually call it.
- The left box is
__repr__— a dunder Python calls when youprint(d), giving a readable name tag instead of a hex address. - The middle box is the
@property: you accessd.is_confidentwith no parentheses, and it computes a derived value on the fly — it looks like data but runs like a method. - The right box is the
@staticmethod: it lives under the class for tidiness but takes noself, so it is called on the class itself (Diagnosis.from_dict(d)) — commonly a factory that builds an instance from raw data.
In short: __repr__ = how it prints, @property = a computed value read like an attribute, @staticmethod = a related helper with no self.
pythonclass Diagnosis:
def __init__(self, cause, confidence):
self.cause = cause
self.confidence = confidence
# __repr__: what you see when you print the object (great for debugging)
def __repr__(self):
return f"Diagnosis(cause={self.cause!r}, conf={self.confidence:.2f})"
# @property: a method you access like an attribute (computed, read-only)
@property
def is_confident(self):
return self.confidence >= 0.8
# @staticmethod: a plain function grouped under the class (no self)
@staticmethod
def from_dict(d):
return Diagnosis(d["cause"], d["confidence"])
d = Diagnosis("db auth", 0.91)
print(d) # Diagnosis(cause='db auth', conf=0.91) ← thanks to __repr__
d.is_confident # True ← accessed like an attribute, no ()
Four small class features that make your objects nice to use and debug. "Dunder" methods (named with double underscores like __repr__) are hooks Python calls for you at special moments.
def __repr__(self):defines what shows when youprintthe object or view it in a REPL. Without it you'd get an unhelpful<Diagnosis object at 0x...>; with it you get a readable summary.{self.confidence:.2f}formats the number to 2 decimals.@propertyturns a method into something you read like a plain attribute —d.is_confidentwith no parentheses. It's computed on the fly and read-only, great for derived values like "is this confident enough?".@staticmethodmarks a function that lives under the class for tidiness but takes noself— it doesn't use instance state.Diagnosis.from_dict(d)is a common pattern: a helper that builds an instance from raw data.
What the output means: print(d) shows Diagnosis(cause='db auth', conf=0.91) thanks to __repr__; d.is_confident is True and is accessed without () because it's a property.
Try this: Add () after d.is_confident and run it — you'll get a TypeError because a property returns the value directly, not a callable. Properties look like data, not methods.
BaseModel (P4) auto-generates a useful __repr__ and validates on construction — which is why the course prefers Pydantic models over hand-written classes for data. But knowing @property and __repr__ helps you read any codebase.12 · Abstract base classes — defining an interface advanced
Imagine handing a teammate a job with a checklist — and them quietly skipping half of it. You tell everyone building a storage backend "you must provide a read and a write." Someone implements read, forgets write, and ships it. It imports fine, constructs fine, and only blows up in production the first time anything calls the missing write. An abstract base class (ABC) is Python's way of making that mistake impossible: it turns "please implement these methods" from a hopeful comment into an enforced contract, checked the moment someone tries to create the object.
The analogy is a blank government form with required fields. The form defines the shape — name here, signature there — but is not itself a valid submission; you can't file the empty template. Only once every required field is filled does it become a usable document. An ABC works the same way: class MemoryBackend(ABC) with @abstractmethod on read and write declares the shape, and Python refuses to instantiate it directly (MemoryBackend() raises TypeError: can't instantiate abstract class). Only a concrete subclass that fills in every abstract method — like a FileMemory that actually reads and writes files — can be created.
The distinction beginners blur is "abstract vs concrete." An abstract method has no real body (just ... or a raise); it exists only to say "you must supply this." A concrete method has a working body. A class is abstract if any of its methods are still abstract, and the beauty is when the enforcement fires: not vaguely at some future call, but immediately at construction, with an error message that names exactly which method you forgot. That early, precise failure is the entire value proposition — you can't accidentally ship an incomplete implementation.
Why an AI engineer meets this: SDKs use ABCs to hand you a slot to fill. The memory tool is exactly this shape — you subclass an abstract base and implement view/create/str_replace, and the SDK's runner drives your methods. You supply the storage; the framework owns the flow. An ABC declares "any subclass must implement these methods." It's how SDKs give you a contract to fill in — like a memory backend where you supply the storage but the SDK drives the calls.
Start at the top box (the abstract base) and follow the two arrows down to the two subclasses; the red boxes mark what Python refuses to build.
- The white box on top is
MemoryBackend(ABC)— it declaresreadandwritewith@abstractmethodand no real bodies. The red arrow shows that calling it directly raisesTypeError: you can't instantiate the template. - The green box (left) is
FileMemory, which implements both methods — soFileMemory()is allowed and builds normally. - The red box (right) is a subclass that still leaves
writeabstract; it is also blocked with aTypeErrorthat names the missing method. That early, precise failure is the whole point of an ABC.
In short: an abstract base defines required methods; only a subclass that fills in all of them can be instantiated, so you can't accidentally ship a half-built interface.
pythonfrom abc import ABC, abstractmethod
class MemoryBackend(ABC):
@abstractmethod
def read(self, path): ... # subclasses MUST implement
@abstractmethod
def write(self, path, data): ...
# a concrete file-based implementation
class FileMemory(MemoryBackend):
def read(self, path):
with open(path) as f: return f.read()
def write(self, path, data):
with open(path, "w") as f: f.write(data)
# MemoryBackend() -> TypeError: can't instantiate abstract class
mem = FileMemory() # OK — it implements both methods
An abstract base class (ABC) defines a contract: it lists methods that any subclass is required to implement, but provides no working body itself. It's how an SDK hands you a shape to fill in — you supply the storage, the SDK calls your methods.
class MemoryBackend(ABC):inherits fromABC, marking it abstract — a template, not something you use directly.@abstractmethodabovedef read(...)anddef write(...)means "every subclass must provide these". The...body is just a placeholder; there's no real implementation here.class FileMemory(MemoryBackend):is a concrete subclass that actually implementsreadandwriteusing real files.- Trying
MemoryBackend()directly raisesTypeError: can't instantiate abstract class— Python enforces the contract.FileMemory()works because it filled in both required methods.
What the output means: mem = FileMemory() succeeds; instantiating the abstract MemoryBackend itself would fail with a TypeError.
Try this: Delete FileMemory's write method and try to create one. Python refuses, listing write as still abstract — the ABC guarantees you can't forget part of the interface.
BetaAbstractMemoryTool and implement view/create/str_replace/etc.; the tool runner calls them (referenced in Ch 4 tool concepts). The capstone's audit backend follows the same "define an interface, swap the implementation" idea when you go from in-memory to durable storage.Worked example · a mini tool registry putting it together
Individually, the last twelve sections can feel like a bag of loose parts. A dataclass here, an enum there, **kwargs off in section 2 — each one sensible on its own, but you're entitled to ask "so what do they actually build together?" This worked example is the answer: every piece snapping into one small, runnable system that answers the two questions a real agent asks on every turn — which tools exist, and am I allowed to run this one right now? If the pieces felt abstract, watching them cohere here is where it clicks.
The mental model is an office with a tool cupboard and a supervisor. The cupboard is the registry — a dict mapping each tool's name to its record. Each tool (a dataclass) is a labelled box carrying its name, its enum risk level, and the run function that does the work. The supervisor is the call() policy gate: you ask for a tool by name, and before handing it over the supervisor checks the risk tag — a read-only tool goes through freely, but a write tool is refused unless you've explicitly shown the "writes allowed" permit. That refusal-by-default is the entire spirit of a safety gate.
Watch specifically for the payoff line, return tool.run(**kwargs). That single statement is the whole chapter in miniature: a function stored as a value (section 1), fetched from a dict by name (dispatch), and called with the caller's arguments spread in via ** (section 2) — all guarded by an enum comparison (section 7) inside a gate. Nothing here is new; it's the same moves you've already met, now load-bearing. Dataclass + enum + type hints + dispatch + **kwargs + __repr__ — a runnable miniature of the capstone's tool system, safe to run with no API key.
tool.run(**kwargs) — the same shape as the capstone's tools + policy.
Follow the arrows left to right; the gate in the middle is a fork with two possible endings on the right.
- The blue box on the left is the incoming
call(name, ...). The first arrow looks the name up in the white REGISTRY box, which maps the name to itsToolrecord. - The amber box is the policy gate: it reads the tool's enum
risk. This is the fork — a write withallow_writesoff takes the red "no" branch to BLOCKED; anything allowed takes the "allowed" branch. - The green box is the payoff,
tool.run(**kwargs): the stored function runs with the caller's arguments spread in via**— a function-as-value, fetched by name, called generically, all behind a safety check.
In short: the registry answers "which tool?" and the gate answers "may I run it?" — dispatch plus a risk check is the entire spine of the capstone's tool system.
mini_registry.pyfrom dataclasses import dataclass
from enum import Enum
from typing import Callable
class Risk(str, Enum):
READ_ONLY = "read_only"
WRITE = "write"
@dataclass
class Tool:
name: str
risk: Risk
run: Callable
def __repr__(self):
return f"Tool({self.name}, {self.risk.value})"
REGISTRY: dict[str, Tool] = {}
def register(tool): REGISTRY[tool.name] = tool
register(Tool("get_pods", Risk.READ_ONLY, lambda ns="staging": f"pods in {ns}"))
register(Tool("scale", Risk.WRITE, lambda name, n: f"scaled {name} to {n}"))
def call(name, allow_writes=False, **kwargs): # a tiny policy gate
tool = REGISTRY[name]
if tool.risk == Risk.WRITE and not allow_writes:
return f"BLOCKED: {tool.name} is a write and writes are off"
return tool.run(**kwargs) # unpack args into the tool
if __name__ == "__main__":
print(REGISTRY["get_pods"]) # Tool(get_pods, read_only)
print(call("get_pods", ns="prod")) # pods in prod
print(call("scale", name="web", n=3)) # BLOCKED (writes off)
print(call("scale", allow_writes=True, name="web", n=3)) # scaled web to 3
Tool(get_pods, read_only)
pods in prod
BLOCKED: scale is a write and writes are off
scaled web to 3
This is everything in P3 working together: a dataclass, an enum, type hints, a registry dict, **kwargs, and __repr__ — a runnable miniature of the capstone's tool system. It answers "which tools exist, and may I run this one right now?"
class Risk(str, Enum)fixes the allowed risk levels;@dataclass class Toolbundles a tool'sname, itsrisk, and therunfunction into one record (with a friendly__repr__).REGISTRY: dict[str, Tool] = {}is the lookup table, andregister(tool)adds a tool under its name. Two tools get registered — a read-only one and a write one.def call(name, allow_writes=False, **kwargs):is the policy gate. It finds the tool, and if the tool is aWRITEwhileallow_writesis off, it returns aBLOCKEDmessage instead of running it.return tool.run(**kwargs)is the payoff line: the**kwargsunpacks whatever arguments were passed straight into the stored function — the same generic-call trick from section 2, now inside a real gate.if __name__ == "__main__":runs the demo only when you execute this file directly, so importing it elsewhere stays quiet.
What the output means: The four prints show: the tool's __repr__; a successful read (pods in prod); a blocked write (BLOCKED: scale ...); and finally the same write allowed through with allow_writes=True giving scaled web to 3.
Try this: Add a third tool with Risk.READ_ONLY and call it through call(...) with no allow_writes — it runs freely because only writes are gated. You've just rebuilt the shape of the capstone's safety policy.
agent/tools.py + agent/policy.py — a dataclass tool with a risk enum, a registry, and a gate that blocks writes. The real one adds more risk levels and rungs, but the structure is exactly this.Exercises expert
Exercise P3.1 — tool dispatch
Context: An agent picks an action by name, and the cleanest dispatch is a dict mapping names to functions — the agent's dispatch table in miniature.
Your task: Make a dict mapping add and mul to two small functions, then call the right one by looking up a name string.
Requirements:
- A dict mapping two names to functions
- Two small functions (add and multiply)
- Look up by a name string and call the result
💡 Hint: Store the functions as dict values and call table[name](...); this is how an agent routes a chosen tool.
Solution
ops = {"add": lambda a,b: a+b, "mul": lambda a,b: a*b}
ops["add"](2, 3) # 5Exercise P3.2 — a Tool dataclass
Context: A tool is naturally a dataclass carrying a callable, so its behaviour lives right next to its metadata — the shape the registry builds on.
Your task: Define a @dataclass Tool with name, risk, and run; create one whose run is a lambda returning a string, and call t.run().
Requirements:
- A dataclass with name, risk, and run fields
- run holds a callable (a lambda)
- Instantiate with a string-returning lambda
- Call
t.run()and get the string
💡 Hint: Give the dataclass a run field typed as a callable and assign a lambda; invoke it with t.run().
Exercise P3.3 — a risk enum + comparison
Context: Classifying risk with an Enum lets a policy function decide danger by identity — here flagging only the irreversible member, a preview of the approval gate.
Your task: Define a RiskClass enum with three members and a function is_dangerous(risk) returning True only for the irreversible member.
Requirements:
- An enum with three risk members
- A function taking a risk member
- Returns True only for the irreversible member
- Returns False for the other two
💡 Hint: Compare the argument against the irreversible enum member; identity comparison is cleaner and safer than matching a raw string.
Solution
Setup to run this snippet
class _RiskClass_t:
_ = None
def __getattr__(self, k): return 'demo'
RiskClass = _RiskClass_t()def is_dangerous(risk):
return risk == RiskClass.IRREVERSIBLE🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.
The classic OOP design question: O(1) get/put via a dict + ordering. OrderedDict makes it clean; the from-scratch dict+DLL version is in D7.
pythonfrom collections import OrderedDict
class LRUCache:
def __init__(self, cap):
self.cap = cap
self.d = OrderedDict()
def get(self, key):
if key not in self.d: return -1
self.d.move_to_end(key) # mark most-recent
return self.d[key]
def put(self, key, val):
if key in self.d: self.d.move_to_end(key)
self.d[key] = val
if len(self.d) > self.cap:
self.d.popitem(last=False) # evict least-recent
An LRU (Least Recently Used) cache keeps a fixed number of items and, when full, throws away the one untouched for the longest. This is the classic object-oriented design interview question; OrderedDict makes it clean because it remembers insertion/access order.
self.d = OrderedDict()stores the items in an order-aware dict;self.capis the maximum size.get: if the key is missing, return-1. Otherwiseself.d.move_to_end(key)bumps it to the "most recently used" end, then returns its value. Touching an item makes it recent — that's the whole idea.put: if the key already exists, move it to the recent end; then set the value.if len(self.d) > self.cap:means we just overflowed, soself.d.popitem(last=False)evicts from the front — the least-recently-used item.
What the output means: After exceeding capacity, the item you accessed or added least recently is the one dropped. Both get and put run in O(1) average time.
Try this: Create c = LRUCache(2), then put(1,1), put(2,2), get(1), put(3,3). Key 2 is evicted, because get(1) made 1 more recent than 2.
LLD rounds love "design the classes for X." Enums for suits/ranks, a frozen dataclass for a card, a class for the deck.
pythonfrom dataclasses import dataclass
from enum import Enum
class Suit(Enum): SPADE=1; HEART=2; DIAMOND=3; CLUB=4
@dataclass(frozen=True)
class Card:
rank: int # 2..14
suit: Suit
class Deck:
def __init__(self):
self.cards = [Card(r, s) for s in Suit for r in range(2, 15)]
"Design the classes for X" is a staple of low-level-design interviews. A deck of cards is the friendly version: it shows off enums for fixed categories, a frozen dataclass for an immutable value, and a normal class to hold the collection.
class Suit(Enum): SPADE=1; HEART=2; ...— an enum is the right tool because there are exactly four suits and no others should ever exist.@dataclass(frozen=True)onCardmakes each card immutable — once created, itsrankandsuitcan't change. Frozen dataclasses are also hashable, so cards can live in sets or act as dict keys.rank: intwith the comment# 2..14means 2 through 14 (11-14 being Jack/Queen/King/Ace);suit: Suitreuses the enum as the field's type.self.cards = [Card(r, s) for s in Suit for r in range(2, 15)]is a nested comprehension: for each suit, for each rank 2..14, build a card — producing all 52 cards in one line.
What the output means: A fresh Deck() holds 52 unique Card objects (4 suits × 13 ranks). Because cards are frozen, two Card(14, Suit.SPADE) values are interchangeable.
Try this: Add a deal(self, n) method that pops n cards off self.cards and returns them. Notice you never need to guard suits against typos — the Suit enum already made that impossible.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A dataclass models a small record with type hints and gives you a readable repr for free — the lesson's mini tool registry starts here.
Your task: Model a tool as a @dataclass with name: str and risk: str, create one, and print it.
Requirements:
- A
@dataclasswith two typed fields - name and risk are strings
- Instantiate one tool
- Print it to show the auto-generated repr
💡 Hint: Decorate with @dataclass and add two annotated fields; printing the instance shows the free repr.
Show solution
Dataclass + type hints from sections 4 and 6. Runnable:
from dataclasses import dataclass
@dataclass
class Tool:
name: str
risk: str
t = Tool("get_pods", "read_only")
print(t) # Tool(name='get_pods', risk='read_only')
Context: Free-text risk invites typos and invalid values. An Enum makes the set explicit, and inheriting from str keeps the value JSON-friendly.
Your task: Replace the free-text risk with an Enum (READ_ONLY, WRITE) inheriting from str, Enum so the value is JSON-friendly, and print the value.
Requirements:
- An Enum with READ_ONLY and WRITE members
- Inherits from
str, Enum - The tool's risk field is the Enum type
- Print the member's
.value
💡 Hint: class Risk(str, Enum) gives members whose .value is the plain string, so they serialize cleanly.
Show solution
The lesson's class Risk(str, Enum). Runnable:
from enum import Enum
from dataclasses import dataclass
class Risk(str, Enum):
READ_ONLY = "read_only"
WRITE = "write"
@dataclass
class Tool:
name: str
risk: Risk
t = Tool("scale", Risk.WRITE)
print(t.name, t.risk.value) # scale write
Context: A tool registry maps names to tools and dispatches calls, and a policy gate blocks write tools unless writes are explicitly allowed — the core of the P3 worked example.
Your task: Build the registry: REGISTRY + register + call(name, allow_writes=False, **kwargs) that blocks WRITE tools unless writes are allowed and passes args through with **kwargs.
Requirements:
- A registry dict and a register function
- Tools carry a callable to run
- call dispatches by name
- WRITE tools are blocked unless
allow_writesis True - Arguments pass through via
**kwargs
💡 Hint: Look the tool up by name, check its risk against allow_writes before running, and forward **kwargs to the tool's callable.
Show solution
The core of the P3 worked example: dispatch + a policy gate + **kwargs. Runnable:
from dataclasses import dataclass
from enum import Enum
from typing import Callable
class Risk(str, Enum):
READ_ONLY = "read_only"; WRITE = "write"
@dataclass
class Tool:
name: str; risk: Risk; run: Callable
REGISTRY: dict[str, Tool] = {}
def register(tool): REGISTRY[tool.name] = tool
register(Tool("get_pods", Risk.READ_ONLY, lambda ns="staging": f"pods in {ns}"))
register(Tool("scale", Risk.WRITE, lambda dep, n: f"scaled {dep} to {n}"))
def call(tool_name, allow_writes=False, **kwargs):
tool = REGISTRY[tool_name]
if tool.risk == Risk.WRITE and not allow_writes:
return f"BLOCKED: {tool.name} is a write and writes are off"
return tool.run(**kwargs)
print(call("get_pods", ns="prod"))
print(call("scale", dep="web", n=3))
print(call("scale", allow_writes=True, dep="web", n=3))
Context: The mutable-default-argument trap — def f(x, seen=[]) — silently shares one list across calls. The fix is the None-sentinel idiom that makes a fresh list each call.
Your task: Write register_all(tools, into=None) that appends to a registry list using the None-sentinel idiom so calls don't share state.
Requirements:
- Default the accumulator to
None, not a list - Create a fresh list inside when it's None
- Append the tools to it and return it
- Two separate calls produce independent lists
💡 Hint: Guard with if into is None: into = [] at the top; never put a list literal in the default, or every call shares it.
Show solution
Section 8's mutable-default trap, fixed the canonical way. Runnable — the two calls stay independent:
def register_all(tools, into=None):
if into is None: # fresh list per call, NOT a shared default
into = []
into.extend(tools)
return into
a = register_all(["get_pods"])
b = register_all(["scale"])
print(a) # ['get_pods'] -- not polluted by b
print(b) # ['scale']
Context: An abstract base class defines an interface: subclasses must implement the abstract method, and instantiating an incomplete one fails — enforcing the contract.
Your task: Use abc.ABC to define a Tool interface with an abstract run(), then two concrete tools, and show an incomplete subclass fails to instantiate.
Requirements:
- A
Tool(ABC)with an@abstractmethod run() - Two concrete subclasses implement run
- The concrete tools instantiate and run
- A subclass missing run raises
TypeErroron instantiation
💡 Hint: Mark run with @abstractmethod; Python refuses to instantiate any subclass that hasn't implemented it.
Show solution
Abstract base class enforcing an interface. Runnable:
from abc import ABC, abstractmethod
class Tool(ABC):
name: str
@abstractmethod
def run(self, **kwargs) -> str: ...
class GetPods(Tool):
name = "get_pods"
def run(self, ns="staging"): return f"pods in {ns}"
print(GetPods().run(ns="prod"))
try:
class Broken(Tool):
name = "broken" # forgot run()
Broken()
except TypeError as e:
print("cannot instantiate:", type(e).__name__)
Context: The chapter's patterns cohere into the capstone's tool system: a dataclass + enum tool, dispatch through a registry, and a policy gate that returns a structured result with an allowed flag.
Your task: Combine dataclass + enum + dispatch + policy into a registry a real agent loop calls: run(name, allow_writes, **kwargs) returning a result dict with an allowed flag; assert a write is blocked by default.
Requirements:
- A dataclass tool with an Enum risk and a callable
- A registry class with register and run
- run returns a dict including an
allowedflag and the result - WRITE tools are blocked when writes are off
- Assert a write is blocked by default
💡 Hint: Return {"tool": ..., "allowed": bool, "result": ...} so the agent loop can branch on allowed; default allow_writes False and assert the block.
Show solution
The P3 patterns cohering into the capstone's tool system shape. Runnable:
from dataclasses import dataclass
from enum import Enum
from typing import Callable
class Risk(str, Enum):
READ_ONLY = "read_only"; WRITE = "write"
@dataclass
class Tool:
name: str; risk: Risk; run: Callable
class Registry:
def __init__(self): self._tools: dict[str, Tool] = {}
def register(self, tool): self._tools[tool.name] = tool
def run(self, tool_name, allow_writes=False, **kwargs):
tool = self._tools[tool_name]
if tool.risk == Risk.WRITE and not allow_writes:
return {"tool": tool_name, "allowed": False, "result": None}
return {"tool": tool_name, "allowed": True, "result": tool.run(**kwargs)}
reg = Registry()
reg.register(Tool("scale", Risk.WRITE, lambda dep, n: f"scaled {dep} to {n}"))
blocked = reg.run("scale", dep="web", n=3)
assert blocked["allowed"] is False
print(blocked)
print(reg.run("scale", allow_writes=True, dep="web", n=3))
✓ Checkpoint — ready for P4 when you can…
- Store a function in a dict and call it by name (tool dispatch).
- Explain what
tool.run(**block.input)does. - Split code across modules and import between them; fix a
ModuleNotFoundError. - Read a type-hinted signature, including
OptionalandLiteral. - Write a class with
__init__and a method that usesself. - Define a dataclass and an enum, and use the enum in a comparison.
Knowledge check check yourself
In this lesson, why does the agent's tool dispatch use tool.run(**block.input), and what does the ** operator actually do at the call site?
Show answer
** spreads a dict into named (keyword) arguments, turning something like {name:'web', replicas:3} into run(name='web', replicas=3). This lets the agent call any tool generically with whatever arguments the model chose, without knowing the tool's parameters in advance.The lesson calls the mutable-default trap a nasty bug for agents. What exactly goes wrong with def add_message(msg, history=[]), and what is the fix the course applies?
Show answer
[] is created once when the function is defined and shared across every call, so messages leak/accumulate across calls (and across agent instances). The fix is history=None plus if history is None: history = [] inside the function, which is why the course's agent sets self.messages = [] inside __init__ (per instance) rather than as a shared default.