Production-ready agents, Prerequisite: The Python you need to read agent code
All the Python this series uses, explained for readers who are new to the language: the interpreter, threads and async, the basic data types, functions, type hints, classes, Pydantic models, error handling, decorators and tests, each shown with real agent code.
Every part of this series has code in it. The code is Python, because the series uses the Python version of Google’s Agent Development Kit (ADK), and so do most published agent examples.
You don’t need to be a Python developer to follow along. You need to be able to read an agent file and understand what each line does, which is a much smaller skill. This post covers exactly that part of Python, using code from the system we build in the series, and it covers everything the later parts use. If a later part shows something you don’t recognise, the explanation is here.
If you already write Python, skip to Part 1. If you want a quick refresher, read the cheat sheet at the end.
the Python you need to read agent code in this series, and what you can skip
If you prefer to watch first, here is the heart of this prerequisite as a short animated explainer.
Video: the Python you need to read agent code, in three minutes. The narration is a synthetic voice; the script and animation are mine.
Where are we in the architecture?
This prerequisite does not add a box. It gives you the Python you need to read the code behind every box below. Part 1.25 walks through the whole picture, layer by layer.
the complete Acme architecture: Maria asks in Gemini Enterprise; the router runs on Agent Runtime behind plugins and Model Armor and calls the marketing and supply agents over A2A; they reach BigQuery and Databricks through Agent Gateway; identity and secrets on the left, models, knowledge and the market watch managed agent for public research on the right, traces, logs and analytics below; above it, shoppers reach Gemini Enterprise for CX, whose conversation insights land in BigQuery, and the Legal industry edition plugs into Gemini Enterprise
What is Python?
Python is a general-purpose programming language. You write the code in plain text files
ending in .py, and a program called the Python interpreter reads each file and runs its
statements in order. The original and most widely used interpreter is CPython, and in this
post “Python” means CPython.
Three things about Python explain most of what you will see in agent code:
- It is easy to read. Python was designed to be readable. Blocks of code are marked by indentation, not brackets, so the way the code is laid out on the page is its structure.
- It is dynamically typed. You don’t have to declare the type of a variable; Python works out the type of each value while the program runs. That makes code quick to write. It is also why agent code adds type hints and Pydantic models: they bring type checking back where getting it right matters.
- It coordinates faster parts. Python is slow compared with languages such as C or Rust, but the heavy computing rarely happens in Python. Libraries do that work in faster languages underneath, and in an agent the slowest step, model inference (the model working out its reply), runs on Google’s servers.
what Python is: you write readable Python, the interpreter runs it, the heavy work happens in libraries written in faster languages, and the model itself runs on Google's servers
What are threads and the GIL, and do they matter for agents?
The short answer: the GIL stops Python from computing on several threads at once, but it does not stop Python from waiting on several things at once. Agents mostly wait, so the GIL rarely matters for them.
Picture a small kitchen with three cooks and one sharp knife. Only the cook holding the knife can chop. If all three cooks have a pile of onions, they pass the knife around and the onions take as long as if one cook had done them all. But if each cook has a cake in the oven, the knife does not matter: all three cakes bake at the same time while the cooks stand around. The cooks are threads, the knife is the GIL, chopping is computation, and the oven is a network call.
A program can do several things at once in two ways. It can use threads (several streams of work inside one running program, sharing its memory), or it can use processes (separate copies of the program, each with its own memory).
Standard Python has a mechanism called the Global Interpreter Lock (GIL), which lets only one thread run Python bytecode (the compiled form of your code) at any moment. You can start ten threads, but they take turns. Because of this, people often call Python single-threaded. That is not accurate. Python has real operating system threads, and they do run at the same time while they are waiting on I/O. When a thread waits for a network response or a file read, it lets go of the lock and another thread runs. The GIL only gets in the way when several threads are all doing heavy Python computation at the same moment.
As of October 2026, there is also a free-threaded build of Python with no GIL. It has been officially supported since Python 3.14, but it is optional: the standard Python download still includes the GIL.
How does this apply to Acme? Mostly it does not. When Maria asks about the Acme Pure Refill
Launch, the agent’s “cakes” are the call to Gemini and the query on campaign_performance, and the
chopping is the few milliseconds of Python in between. Look at where a single agent request spends
its time:
where one agent request spends its time: a few milliseconds running Python, and whole seconds waiting for the model and the database, which is why threads versus async matters more than raw speed
An agent is I/O-bound. I/O (input and output) here means sending requests over the network and waiting for the answers. Python runs for a few milliseconds, then waits seconds for the model or the database. The GIL does not slow down waiting. What matters is that while one user’s request is waiting, the server keeps working on other users’ requests. Python gives you two ways to do this:
- Threads, which suit a small number of slow tasks. The evaluation tools in Part 10 grade many test answers on a thread pool (a fixed group of threads that share the work), because each grading call spends most of its time waiting for a model.
- async, which handles thousands of waits at once at very little cost per wait. ADK and its web server are built on async, which I explain in detail below.
The GIL matters when Python itself does heavy computation for a long time, such as training a model in pure Python. That kind of work uses libraries such as NumPy or JAX, which do the computing outside the interpreter, or uses several processes. You need neither in this series.
Here is the difference in one experiment. Start three threads and give each the same job, first a job that waits and then a job that computes (timings are rough and depend on the machine):
1. Each thread runs time.sleep(2), standing in for a 2-second model call.
Total: about 2 seconds. While one thread sleeps it releases the GIL, so all three wait at once.
2. Each thread sums 50 million numbers in a pure-Python loop.
Total: about three times as long as one sum. Only one thread can run bytecode at a time,
so the threads take turns and finish no sooner than running the sums one after another.
An agent request looks like case 1, not case 2, which is why the GIL rarely matters here. Case 1 is the cakes in the oven; case 2 is three cooks sharing one knife.
Is Python the best language for AI?
For building agents today, Python is the most practical choice, although it is not the only one that works.
Why it is the default:
- The tools come to Python first. The main machine learning frameworks (PyTorch, JAX, TensorFlow), data libraries (pandas, NumPy) and model SDKs, including Google’s Gen AI SDK, ship for Python first, and new features usually appear there first.
- Most examples are in Python. Documentation, tutorials, notebooks and sample agents mostly use it, so help is easy to find.
- It is quick to change. Building an agent means trying something, measuring it and adjusting it, over and over. Short, readable code makes each round faster.
- Speed is rarely the problem. As the diagram above shows, most of the time in a request is spent waiting for the model and the data, not running Python.
Where it is weaker:
- Raw speed. Heavy computation is much slower in Python than in Go, Java, C++ or Rust.
- Errors show up late. Because types are checked while the program runs, some errors only appear in production. Type hints, Pydantic and tests close most of that gap.
- Packaging used to be hard. Managing libraries and their versions was painful for a long
time. Modern tools such as
uvhave made it much better.
ADK is also available in Java, Go and TypeScript. If your organisation’s systems are written in one of those languages, using that version of ADK is a reasonable choice. This series uses Python because it has the most examples and gets new features first.
The common mistake is to pick the language for raw speed. In an agent the model call dominates, so pick it for the people and systems around the agent. A short test, in order:
1. Is there no strong reason to do otherwise? -> Python (most examples, features first)
2. Does your team run and support only Java, Go or TS? -> ADK in that language
3. Does one step do heavy computation in a tight loop? -> keep Python, push that step into a
library or a separate service
Acme fits line 1: a small team, examples to copy, and every request spends its time waiting on Gemini, BigQuery and Databricks.
How do I run Python?
python3 --version # check it is installed; you need 3.11 or newer
python3 my_agent.py # run a file
Agent projects depend on third-party libraries (code written by others, such as ADK). You install these into a virtual environment: a separate set of installed packages for each project, so that two projects never fight over library versions.
Think of two hobbies, woodwork and baking, each with its own toolbox. If they shared one box, the baker’s new whisk might replace the woodworker’s old one that a half-finished project still needs. Separate boxes mean each hobby keeps exactly the tools it was built with. Each box is a virtual environment, and each tool is a library at a fixed version. For Acme, the same rule means any two agent projects that are built and deployed separately each get their own box, so one can move to a new ADK version without breaking the other.
python3 -m venv .venv # create the environment once
source .venv/bin/activate # switch it on in this terminal
pip install google-adk # install ADK into it
From Part 2 onwards we use agents-cli, which handles this for you with uv, a faster
package installer. The idea is the same: each project has one set of libraries, listed in a file
called pyproject.toml. With uv, uv run python my_agent.py runs a file inside the project’s
environment without you having to activate it first.
Two rules of Python syntax apply from the very first line:
- Indentation matters. The lines indented by four spaces under
def,iforforare its body. Python does not use curly brackets for blocks. #starts a comment. Python ignores the rest of the line.
What kinds of data does an agent handle?
Almost every value an agent deals with is one of five data types (kinds of value). Once you can recognise them, you can read the input and output of any tool.
Start with a shop receipt. The shop’s name is text, the total is a number, the lines are an ordered list of items, and each line is a little record with labelled parts (item, quantity, price). The whole receipt is a record that holds a list of records. In Python, text is a string, the total is an int or float, the lines are a list, and each labelled record is a dict:
receipt = {"shop": "Corner Market", "total": 7.5,
"lines": [{"item": "milk", "qty": 2, "price": 1.5},
{"item": "bread", "qty": 1, "price": 4.5}]}
receipt["lines"][1]["item"] # 'bread': second line, its item
How does this apply to Acme? A tool result has exactly the same shape: a record with a status,
holding a list of rows, each row a dict such as {"brand": "Acme Pure", "spend_eur": 283590}.
the data shapes in every agent: text, numbers, lists, dictionaries, and dictionaries inside dictionaries, which is exactly what JSON looks like
- A string (
str) is text in quotes:"Acme Pure". - An int or float is a number (a whole number or a decimal):
283590,87.4. True,FalseandNonemean true, false and no value.- A list is an ordered sequence in square brackets:
["Acme Pure", "Verde"]. You get an item by its position, counting from zero:brands[0]is"Acme Pure". - A dict (dictionary) is a set of key and value pairs in curly brackets:
{"brand": "Acme Pure", "spend_eur": 283590}. You get a value by its key:result["spend_eur"].
A tool returns a table of results as a dict that holds a list of dicts. That shape matches JSON exactly, so when ADK hands a tool’s result back to the model, it serialises the dict to JSON (turns it into JSON text).
Two more ways of reading a dict appear all through the series, because agent state (the data an agent keeps during a conversation, covered in Part 8) is a dict:
state.get("user:region") # the value, or None if the key is missing
state.get("user:region", "all") # the value, or "all" if missing
state["temp:token"] = token # set a value
d["key"] raises an error if the key is missing, while d.get("key") returns None. The
series also uses := (the assignment expression), which stores a value and tests it in one
step: if region := state.get("user:region"): reads “put the region in region, and carry on
only if it has a value”. Keys are plain strings, so "user:region" is one key; the user:
prefix is an ADK naming convention, explained in Part 8.
state = {"user:region": "Western Europe"}
if region := state.get("user:region"):
print("filter to", region) # runs: region is "Western Europe"
state = {}
if region := state.get("user:region"):
print("filter to", region) # skipped: region is None
Our tools also use a list comprehension, which builds a new list from an existing one in a single expression:
refused = [m for m in measures if m not in allowed]
# "every m in measures that is not in allowed"
Read it from the middle outwards: for each m in measures, keep m if it is not in
allowed. The same syntax with curly brackets builds a dict (a dict comprehension):
{row["brand"]: row["spend_eur"] for row in rows}.
What is a function?
A function is a named block of code that takes inputs (parameters) and gives back an output. In ADK, every tool is a function, so this section is worth reading carefully.
The bottom line: a function is a recipe with a name, and in ADK the model reads only the recipe card, never the method.
Think of a recipe card pinned in a restaurant kitchen. It has a title (“Pancakes”), a list of ingredients with amounts (“2 eggs, 200 g flour”), a note at the top (“serves 4; use oat milk if asked”) and, underneath, the method. A waiter who has never cooked can still take an order from the card: they know what the dish is called, what the kitchen needs, and what the customer gets. They never read the method; the kitchen follows it. In a Python function, the title is the name, the ingredients and amounts are the parameters and their type hints, the note is the docstring, the method is the body, and the dish is the return value.
How does this apply to Acme? The model is the waiter and ADK is the kitchen. The model reads
the name, type hints and docstring of get_spend, writes the order (a function call), and ADK runs
the body. Where people get tripped up is thinking the model runs the code; it never sees it.
anatomy of a Python function: def and a name, parameters with type hints and a default, a docstring describing the arguments, and a return value; then two ways to call it
Four things to notice:
- Type hints (
brand: str) say what type each parameter should be. Python does not enforce them while the program runs, but ADK reads them to build the tool schema that tells the model what to send. So in agent code, type hints are a must. str | None = Nonemeans a string or no value, and no value if you leave it out. This is how you declare an optional parameter.- The docstring, the text in triple quotes just below
def, describes the function and each argument. ADK sends it to the model as the tool’s description, so write it with the model as the reader. - Keyword arguments (
brand="Verde") name each argument where you call the function. Agent code uses them everywhere because they are clear and do not depend on the order of the arguments.
Here is what the model does with all of that, using the get_spend in the diagram above with the
toy data from the Try it section at the end of this post:
1. Maria asks: "What did Verde spend in Germany?"
2. The model has never seen the function's code. It has seen only the name, the type hints and
the docstring, which lists "One of Acme Pure, Verde, Harvest Row".
3. The model replies with a function call, not text:
get_spend(brand="Verde", region="Germany")
4. ADK runs the function and returns its dict to the model:
{"status": "ok", "brand": "Verde", "region": "Germany", "spend_eur": 120400}
5. The model writes the answer from that dict.
The model chose the tool and filled in the arguments, spelling “Verde” exactly as the docstring lists it. Your code chose the figure. A vague docstring (“brand: a brand”) would leave the model guessing at the spelling.
Which other type hints will I see?
list[str]: a list of strings.dict[str, int]: a dict with string keys and whole number values.Any: any type. Used where the shape really is open, such as a row that comes back from a database.-> None: the function returns nothing useful.Callable[[dict], None]: a function that takes a dict and returns nothing. Used when a function takes another function as a parameter.Literal["marketing", "supply", "both"]: exactly one of these strings. Useful when a model has to choose from a fixed set.
Can I pass a function around like a value?
Yes. Passing check_sql hands over the function to call later; writing check_sql() calls it
now. It is the difference between leaving your phone number with a receptionist (“ring me when
the parcel arrives”) and ringing the receptionist yourself right now. The number is the function
without brackets; the ring is the call with brackets; the receptionist is ADK, which rings at the
right moment.
In Python a function is a value like any other, and you can hand it to another function without calling it:
def check_sql(tool, args, tool_context):
if "DELETE" in args.get("sql", "").upper():
return {"status": "error", "error": "read-only"} # stop: use this instead
return None # carry on as normal
agent = Agent(name="marketing", tools=[run_query], before_tool_callback=check_sql)
check_sql without brackets passes a reference to the function, so ADK can call it later,
before every tool call. check_sql() with brackets would call it straight away. This is how
callbacks work in Part 3 and plugins in Part 9: you register a function with ADK, and ADK
calls it at the right moment while handling a request. Note the convention: returning None
means “carry on as normal”, and returning a value means “use this result instead”.
Here is check_sql at work on two tool calls the model might make:
1. The model calls run_query with {"sql": "DELETE FROM campaign_performance"}.
ADK first calls check_sql(tool, args, tool_context).
check_sql returns {"status": "error", "error": "read-only"}.
run_query never runs. The model receives the error and tells Maria it can only read data.
2. The model calls run_query with {"sql": "SELECT SUM(media_spend_eur) FROM campaign_performance"}.
check_sql returns None, so ADK carries on and run_query runs as normal.
The model decided what to send; your code decided whether it ran. And if you had written
before_tool_callback=check_sql(), Python would call it immediately with no arguments and stop
with TypeError: check_sql() missing 3 required positional arguments, before the agent even
exists.
Two related pieces of syntax:
*argsand**kwargsin a function’s definition collect any extra positional and keyword arguments.def log_call(**kwargs):accepts any keyword arguments as one dict. Callingrun(**settings)does the reverse: it unpacks a dict into keyword arguments.lambdadefines a small function with no name in a single expression:sorted(rows, key=lambda r: r["spend_eur"])sorts rows by spend.
Both, on concrete values:
settings = {"brand": "Verde", "region": "Germany"}
get_spend(**settings) # same as get_spend(brand="Verde", region="Germany")
rows = [{"campaign": "Acme Pure Refill Launch", "spend_eur": 283590},
{"campaign": "Verde Spring Refresh", "spend_eur": 120400}]
sorted(rows, key=lambda r: r["spend_eur"])
# Verde Spring Refresh (120400) comes first, then Acme Pure Refill Launch (283590)
What is at the top of an agent file?
Almost every agent file starts with this pattern:
three things you see at the top of every agent file: reading a setting from an environment variable with a default, a multi-line prompt in triple quotes, and an f-string that puts values into text
os.getenv("MODEL", "gemini-flash-latest")reads a setting from the environment and uses the second argument if it is not set. Agent settings such as the model name or the project ID live in environment variables (named settings the operating system passes to a program), often loaded from a.envfile, so the same code runs on your laptop and in the cloud. Secrets such as passwords never go in code; Part 7 shows where they are kept instead.- Triple quotes let a string run over several lines, which is how agent instructions (prompts) are written.
- An f-string starts with
f. Anything inside{curly brackets}in it is replaced by its value.{283590:,}formats the number with thousands separators.
Two standard library modules (modules that come with Python) appear in nearly every file:
import json, logging
text = json.dumps({"brand": "Acme Pure"}) # dict -> JSON text
data = json.loads(text) # JSON text -> dict
log = logging.getLogger(__name__)
log.info("query ran", extra={"rows": 12}) # a log line, not a print
json turns dicts into JSON text and back again, for any data sent over the network. logging
writes messages with a severity level (debug, info, warning, error) that the platform can
collect and search. Production code logs instead of calling print, and Part 11 builds on this
for traces and cost reports. __name__ is the name of the current module, so each log entry
records where it came from.
How do the files in a project fit together?
Agent code is split across several files, and import statements connect them.
how an agent project is organised: the app folder is a package, files import from each other with a dot, and installed libraries such as ADK are imported by name
from google.adk.agents import Agentbrings in theAgentclass from the installed ADK library.from .tools import query_marketing_measuresbrings in a function fromtools.pyin the same folder. The leading dot means a relative import: import from the current package.- A folder that contains an
__init__.pyfile is a package, which Python treats as one unit you can import. In our projects, theapp/folder is the package.
You will see this line at the bottom of scripts:
if __name__ == "__main__":
main()
It means: run main() only when this file is run directly, not when another module imports
it.
What are classes and objects?
You won’t need to write many classes in this series, but you will see them everywhere, because
Agent, App and Runner are all classes.
The bottom line: a class is a blank form; an object is one filled-in copy of it. A gym’s membership form has the same boxes for everyone (name, plan, start date) and says what the gym can do with a member (renew, freeze). Fill it in for Sam and you have one member; fill it in for Maria and you have another. Same form, two separate records. The form is the class, each filled-in copy is an object, the boxes are attributes, and the things you can do with a member are methods.
How does this apply to Acme? Agent is the blank form, with boxes such as name, model,
instruction and tools. Agent(name="marketing", ...) fills in one copy for the marketing agent,
and another call fills in a separate copy for the supply agent.
classes and objects: a class is a template, calling it creates an object that holds data and functions; Agent and App in ADK are classes you call with keyword arguments
- A class is a template. Calling it like a function creates an object (an instance of the class).
- An object holds data in attributes (
c.name) and has its own functions, called methods (c.spend_k()). The dot reads “thenameofc”. __init__runs when the object is created, andselfmeans the object itself.
So root_agent = Agent(name="spend_agent", tools=[get_spend]) means: create an Agent object
with this name and these tools, and call it root_agent. Then App(root_agent=root_agent)
creates a second object that contains the first.
One class can build on another. class RateLimitPlugin(BasePlugin): means a RateLimitPlugin
is a BasePlugin, with some behaviour changed. This is called inheritance. ADK provides the
base class with methods that do nothing, and you override (replace) only the ones you need.
That is how plugins work in Part 9.
Here is the same idea on the Campaign class from the diagram:
class LaunchCampaign(Campaign): # a LaunchCampaign is a Campaign...
def spend_k(self) -> str: # ...with spend_k replaced
return f"{round(self.spend / 1000)}k"
c = LaunchCampaign("Acme Pure Refill Launch", 283590)
c.name # 'Acme Pure Refill Launch' (inherited: __init__ comes from Campaign)
c.spend_k() # '284k' (overridden: Campaign's version returns 284)
What is a dataclass?
Many classes only need to hold a few named fields. A dataclass writes the __init__ method
for you:
from dataclasses import dataclass, field
@dataclass
class Step:
kind: str # "local", "federated" or "delegate"
measures: list
objects: list = field(default_factory=list) # a new empty list for each Step
reason: str = ""
step = Step(kind="local", measures=["spend_eur"])
The router in Part 5 stores its plan as dataclasses like this one.
field(default_factory=list) gives every new object its own empty list, instead of one list
shared by all of them.
s1 = Step(kind="local", measures=["spend_eur"])
s2 = Step(kind="federated", measures=["otif_pct"])
s1.objects.append("campaign_performance")
s1.objects # ['campaign_performance']
s2.objects # [] (its own list, untouched)
Python will not even let you write the shared version: objects: list = [] stops with
ValueError: mutable default <class 'list'> for field objects is not allowed: use default_factory.
What is a Pydantic model?
The bottom line: a dataclass is a labelled box; a Pydantic model is a labelled box with a clerk who checks what goes in.
Think of a passport desk. The form says “date of birth: a date”. A clerk who just files forms (a dataclass) accepts “a long time ago”. A clerk who checks forms (Pydantic) turns the text “1990-03-07” into a proper date, and hands back “date of birth: not a valid date” for “a long time ago”, before anything is stamped.
A dataclass holds data but does not check it: Step(kind=42) is accepted. Data that comes from
outside your code, whether from a model, an API or a user, should be checked, and that is the job
of Pydantic, a validation library (a library that checks data against rules). ADK and the
Gen AI SDK use it throughout.
How does this apply to Acme? A Pydantic model is a class that declares the exact shape of
some data: the field names and their types. It builds on BaseModel, and the clerk’s two answers
appear below as a converted spend_eur and a ValidationError:
from pydantic import BaseModel, Field, ValidationError
class SpendRow(BaseModel):
brand: str
spend_eur: int
ctr: float | None = Field(default=None, description="clicks / impressions")
row = SpendRow.model_validate({"brand": "Acme Pure", "spend_eur": "283590"})
row.spend_eur # 283590, an int: the text was converted safely
row.model_dump() # back to a plain dict
SpendRow.model_validate({"brand": "Acme Pure", "spend_eur": "a lot"})
# ValidationError: spend_eur: Input should be a valid integer, unable to parse
# string as an integer
a Pydantic model checks data against a declared shape: good data becomes a typed object, bad data raises a ValidationError that names the field and the problem
How it behaves:
- Good data becomes a typed object. Each field is checked, and values are converted where
that is safe, such as the string
"283590"to the whole number283590. - Bad data is rejected with a clear reason. The
ValidationErrornames the field and the problem, and no half-valid object is created. Field(description=...)attaches a description that tools and models can read.model_validatechecks a dict;model_dumpturns the object back into a dict.
Agents need this because a model’s output is text. When the output has to follow a fixed shape, such as a score and an explanation, you give a Pydantic model as the schema (the required shape). The model is told to answer in that shape, and Pydantic checks that it did. This is called structured output, and the evaluation judge in Part 10 works exactly like this:
class Verdict(BaseModel):
score: int # 1 to 5
explanation: str
The same pattern appears in ADK as output_schema on an agent, and inside ADK itself: its
events, sessions and settings are all Pydantic models. You can read class X(BaseModel): as “a
schema with named, typed fields that checks any data put into it”.
Which one should you reach for? The cheapest that does the job:
Data you build yourself, used once, a few keys -> plain dict
Data you build yourself, passed around, fixed fields -> dataclass (the router's Step)
Data from outside: a model, an API, a user -> Pydantic model (SpendRow, Verdict)
The misconception to drop: type hints alone do not check anything at run time. Only the Pydantic clerk does.
What happens when something fails?
The bottom line: a tool should turn a failure into a clear message the model can act on, not a crash.
Ask a shop assistant for trainers in size 9 when there are none. A good assistant says “no size 9
in stock; we have 8 and 10”. A bad one walks off without a word. The first answer lets you choose
again; the second leaves you guessing. A tool that returns {"status": "error", ...} is the good
assistant; an uncaught exception is the one who walks off.
Things will fail: a table is missing, a network call times out, or the model asks for a brand that does not exist. In Python, a failure is raised as an exception, and an exception that nobody catches stops the program.
How does this apply to Acme? A tool should never crash the agent. It should return an error that the model can understand and act on, just like the assistant naming sizes 8 and 10:
errors in tools: check the input and return a clear error the model can act on, and catch failures with try/except instead of letting the tool crash
try:/except ...:runs code that might fail and handles the exception if it does.- Returning
{"status": "error", "error": "..."}with a clear message lets the model try again with a valid brand or tell the user exactly what is missing. A crash tells it nothing. - Every tool in our system returns the same shape, with
statusfirst, so the model only has to learn one pattern.
Asked about “Acme Gold”, get_spend answers unknown brand Acme Gold; use Acme Pure, Verde, Harvest Row, so the model can ask Maria which brand she meant or retry with a real one. That retry
inside one turn is the inner loop from Part 1.5.
The series also uses raise, which throws an exception on purpose, and assert, which
stops the program if a condition that must be true is false. Both appear in setup code and tests,
never in tools.
What are async and await, and why do agents need them?
The bottom line: async lets one program wait for many slow things at the same time, instead of waiting for them one after another.
Picture a waiter with three tables. A blocking waiter takes table 1’s order, stands at the kitchen
hatch until that dish is ready, carries it out, and only then walks to table 2. An async waiter
takes table 1’s order, hands it to the kitchen, takes table 2’s and table 3’s while the first dish
cooks, and carries each plate out as the hatch bell rings. Same kitchen, same dishes, a third of
the waiting. The waiter is the event loop, each order is a task, handing it in and walking off
is await, and the bell is the answer arriving.
What is the problem?
As the timing diagram earlier showed, an agent spends most of its time waiting. Ordinary (synchronous) Python waits by blocking: the line that is waiting holds up everything after it. Here is a synchronous function that queries three systems, each taking two seconds:
import time
def ask(team: str) -> str:
time.sleep(2) # stands in for a 2-second network call
return f"{team}: done"
start = time.time()
results = [ask("marketing"), ask("supply"), ask("finance")]
print(results, round(time.time() - start), "seconds") # 6 seconds
This takes six seconds, because each call waits for the one before it to finish. On a server running this code, every other user is stuck while one user’s request spends six seconds waiting, unless each request gets its own thread. A thread per user works for tens of users, but not for thousands.
How does async fix it?
async code waits in a different way. Instead of blocking, an async function pauses at
each wait and hands control back, so other work can run in the meantime. A single scheduler, the
event loop, keeps track of every paused task and resumes each one when its answer arrives.
import asyncio, time
async def ask(team: str) -> str:
await asyncio.sleep(2) # pause here; let other work run
return f"{team}: done"
async def main():
start = time.time()
results = await asyncio.gather(ask("marketing"), ask("supply"), ask("finance"))
print(results, round(time.time() - start), "seconds") # 2 seconds
asyncio.run(main())
three slow calls of two seconds each: without async they run one after another and take six seconds; with async they wait together and take about two
The same work gives the same results in about two seconds, because the three waits overlap. On a server, the same idea lets one process serve many users at once: while one user’s request waits for Gemini, the event loop deals with everyone else’s.
How does this apply to Acme? The three tables are the router’s calls. When Maria’s question
needs both campaign spend and supply figures, the router hands one order to the marketing agent
and one to the supply agent with asyncio.gather, and carries each answer back as it arrives
(Part 5). Meanwhile the same process serves Sam and every other user, the way the waiter serves
other tables.
The keywords one at a time:
async defdefines a coroutine function, a function that can pause. Calling it does not run it straight away; it returns a coroutine that you then await.await somethingruns it and pauses the current function until it finishes, so other work can run meanwhile. You can only useawaitinside anasync def.asyncio.gather(a, b, c)starts several coroutines at the same time and waits for all of them. This is how an agent asks two specialists at once (Part 5).asyncio.run(main())starts the event loop from an ordinary script.async for event in ...takes results one at a time as they arrive.async withopens a resource, such as a network connection, and closes it when the block ends, with awaits allowed in between.
The main trap. Inside async code, a blocking call, such as time.sleep(2) or a library that
does not support async, blocks the whole event loop and freezes every user. Use the async version
of the library, or move the blocking call onto a thread with
await asyncio.to_thread(blocking_function).
You will see async most often in the Runner, which streams events while the agent works:
async for: the Runner hands your loop one event at a time as the agent works, so you can show progress instead of waiting for the end
async for event in runner.run_async(user_id="maria", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
This means: for each event the agent sends, as soon as it is sent, print the final answer when it arrives. The tool call and the tool result arrive first, as separate events.
Tools don’t have to be async. ADK accepts both an ordinary def tool and an async def tool.
Use async def when the tool itself calls something slow that has an async client library.
A quick guide for a tool, simplest first:
The tool only computes, or calls something fast -> plain def
The tool waits on a service with an async client -> async def, and await the client
The tool must use a blocking library inside async -> await asyncio.to_thread(blocking_function)
What are decorators, generators and context managers?
Decorators
The @ line above a function or class is a decorator. It wraps the definition that follows
with extra behaviour, without changing its code.
@dataclass # write __init__ for this class
class Step: ...
@functools.cache # remember the result; run the body only once
def get_session_service():
return VertexAiSessionService(project=PROJECT)
@pytest.fixture # this function provides test data
def runner(): ...
Each one works like a label: “this class is a dataclass”, “this function’s result is
cached”, “this is a test fixture”. Web servers also use decorators such as @app.get("/"),
meaning “this function handles requests to /”.
You can see @functools.cache work: call get_session_service() twice and
get_session_service() is get_session_service() gives True. Both calls return the same
object, and a print inside the body would print only once, on the first call.
yield and generators
return gives back one value and ends the function. yield gives back a value and
pauses the function, ready to give the next value when asked. A function that contains
yield is a generator.
def stream_rows(rows):
for row in rows:
yield row # hand one row back, pause, continue when asked
for row in stream_rows(all_rows):
print(row)
Driven by hand, one step at a time:
gen = stream_rows(["row 1", "row 2"])
next(gen) # 'row 1' (runs until the first yield, then pauses)
next(gen) # 'row 2' (resumes, runs to the next yield, pauses again)
next(gen) # raises StopIteration: no rows left, which is how a for loop knows to stop
Agents use generators for streaming: each result is sent as soon as it is ready, instead of
all results coming back at the end. Combined with async def, the same idea is an async
generator, read with async for, and runner.run_async is one.
with: context managers
with open("SKILL.md") as f:
text = f.read()
# the file is closed here, even if reading failed
with opens a resource and guarantees it is released when the block ends, even if an
exception is raised. It is used for files, network connections and database sessions, and an
object that supports it is called a context manager.
How do I test code?
From Part 10 onwards, we check that the system works with tests: small functions that run your code and check the result. The test framework is pytest.
The bottom line: a test is a question about your code with a known right answer, asked again
every time the code changes. It is the test button on a smoke alarm: press it, expect a beep. If
one day there is no beep, you know something broke before there is a fire. In code, pressing the
button is calling the function, the beep is the expected result, and assert is you listening.
How does this apply to Acme? The button is “ask get_spend for a brand that does not exist”;
the beep is "status": "error":
# tests/test_tools.py
from app.tools import get_spend
def test_unknown_brand_is_an_error():
result = get_spend("Acme Gold") # not one of Acme Pure, Verde, Harvest Row
assert result["status"] == "error"
Run against the get_spend from “What happens when something fails?”, the brand check returns
{"status": "error", "error": "unknown brand Acme Gold; use Acme Pure, Verde, Harvest Row"}, the
assert holds and pytest reports 1 passed. If someone later deletes the brand check, the tool
returns "status": "ok" for a brand that does not exist, the assert fails, and pytest reports
1 failed and points at the assert result["status"] == "error" line.
- A test is a function whose name starts with
test_, in a file whose name starts withtest_. assertchecks that a condition is true. If it is not, the test fails and shows the line that failed.- Run all the tests with
pytest, oruv run pytestin an agents-cli project.
Can I read a real agent file now?
Here is the marketing agent from this series, shortened slightly. Every line uses something covered in this post:
the marketing agent file read with the Python from this post: imports, an environment variable, a triple-quoted instruction, an object built with keyword arguments, lists of functions, and the App
If you can read this file, you can follow every part of the series. The diagram below shows where each piece comes back:
where each piece of Python appears in the series, so you know what to come back to
Cheat sheet
x = 5: put 5 in the variable x"text"/"""text""": a string / a multi-line stringf"Hi {name}": a string with a value filled in[a, b]/items[0]: a list / its first item{"k": v}/d["k"]/d.get("k"): a dict / the value for key k / the same, or None if missing[x for x in xs if ok(x)]: a list comprehension that builds a new list from an existing onedef f(a: str) -> dict:: a function that takes a string and returns a dicta: str | None = None: an optional parameter,Noneif left outf(a="x"): call f with a keyword argumenttools=[f]: pass a reference to the function, to be called later**kwargs: any extra keyword arguments, as a dictfrom .tools import f: import f from tools.py in the current packageAgent(name="x"): create an Agent objectobj.name,obj.run(): an attribute / a method of the objectclass A(B):: A builds on B and changes some behaviour@dataclass: a class that holds named fieldsclass X(BaseModel):: a Pydantic model that checks its dataX.model_validate(d)/x.model_dump(): check a dict / turn it back into a dicttry: ... except E: ...: run this block; if exception E is raised, handle itasync def/await x: a function that can pause / wait for x without blockingawait asyncio.gather(a, b): run a and b at the same timeasync for e in s:: take each e from s as it arrivesyield v: send v and pausewith open(p) as f:: open a resource and always release it@something: wrap the next function with extra behaviouros.getenv("K", "d"): environment variable K, or d if not setif x := f():: store x and test it in one stepassert x == y: stop (or fail the test) if the condition is false
Try it
Save this as practice.py and run python3 practice.py. It needs one library:
pip install pydantic.
import asyncio, time
from pydantic import BaseModel, ValidationError
class SpendRow(BaseModel):
brand: str
spend_eur: int
def get_spend(brand: str, region: str | None = None) -> dict:
"""Return media spend in EUR for one brand."""
toy_data = {"Acme Pure": 283590, "Verde": 120400}
if brand not in toy_data:
return {"status": "error", "error": f"unknown brand {brand}; use {list(toy_data)}"}
return {"status": "ok", "brand": brand, "region": region or "all", "spend_eur": toy_data[brand]}
print(get_spend("Acme Pure"))
print(get_spend(brand="Verde", region="Germany"))
print(get_spend("Harvest Row"))
# Pydantic: one good row, one bad row
print(SpendRow.model_validate({"brand": "Verde", "spend_eur": "120400"}))
try:
SpendRow.model_validate({"brand": "Verde", "spend_eur": "lots"})
except ValidationError as e:
print("refused:", e.errors()[0]["msg"])
# async: three 1-second waits take about 1 second, not 3
async def ask(team: str) -> str:
await asyncio.sleep(1)
return f"{team}: done"
async def main():
start = time.time()
print(await asyncio.gather(ask("marketing"), ask("supply"), ask("finance")))
print(f"took {time.time() - start:.1f} s")
asyncio.run(main())
Things to try:
- Change the docstring and notice that the output does not change when you run the script. A docstring only changes behaviour when a model reads it, which is what Part 1 is about.
- Replace
await asyncio.sleep(1)withtime.sleep(1). The total goes up to three seconds, which is the blocking trap from the async section.
What did we learn?
- Agent code needs only a reading knowledge of Python: functions, the basic data types, classes, Pydantic models, async and a few pieces of syntax.
- An agent is I/O-bound, so it spends most of its time waiting for the model and the data, and async lets one server keep many of those waits going at once.
- In ADK every tool is a Python function, and its type hints and docstring become the description the model reads.
- Pydantic models check data that comes from outside your code, which is how structured output from a model is validated.
- Callbacks and plugins work by passing a function to ADK, and returning
Nonemeans carry on as normal. - If you can read the marketing agent file at the end of this post, you can follow every part of the series.
What comes next?
Production-ready agents, Prerequisite 2: The business terms and the data behind the agents explains the business behind the agents: the marketing and supply terms with real numbers, the four tables and who owns them, and how the system decides whether to federate or delegate each number. Then Part 1 starts the series properly: what a model, a tool and an agent are, the three ADK classes every agent runs on, and the system the series builds.
Sources
- The Python Tutorial (official)
- Python FAQ: what is Python?
- Python glossary: global interpreter lock
- Python HOWTO: free-threaded Python
- PEP 703: making the GIL optional
- Python: virtual environments and packages
- Python: errors and exceptions
- Python: classes
- Python:
dataclasses - Python:
asyncio - Python: type hints (
typing) - Python:
logging - Pydantic: models
- pytest: get started
- uv documentation
- ADK: function tools
- ADK: get started
Opinions are my own. Every fact in this post comes from the public documentation linked above.