Chapter 7 of 21 · intermediate
Multi-tool calling and tool loops
What this session covers
Last week the model made one function call and you handed the result back. That covers "what's 26.2 miles in km." It does not cover "which items under $30 are in stock and what would one of each cost" — a question nobody can answer in one call, because you have to discover what exists before you can price it. This hour turns the round trip into a loop: give the model several tools, keep executing whatever it asks for, and stop when it stops asking. That loop has a name people charge money for — an agent — and by the end of the hour you'll have written one in about fifteen lines.
The three moving parts are a dispatch dict mapping tool names to Python
functions, a conversation you keep appending to, and a bounded while. The
model plans the chain itself: which tool, what order, how many times. Your
code just executes and keeps the transcript honest.
OpenAI
The basic example is a tiny office-supplies store with two tools — one lists product names, one prices a specific item. The question forces chaining: the model must list, then price several items, then filter and add.
"""Week 6 - Multi-tool calling and tool loops (OpenAI).
Last week was one tool, one round trip. Real questions need several
calls the model plans itself: look one thing up, then another, then
combine. The pattern is a while loop — keep executing whatever the
model asks for until it stops asking and answers.
"""
import json
import os
import sys
from dotenv import find_dotenv, load_dotenv
from openai import OpenAI
load_dotenv(find_dotenv())
if not os.environ.get("OPENAI_API_KEY"):
sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")
client = OpenAI()
# region: the-tools
# A tiny office-supplies store. Two tools the model must combine: one to
# discover what exists, one to price a specific item.
PRODUCTS = {
"notebook": {"price": 3.50, "stock": 120},
"pen": {"price": 1.25, "stock": 0},
"stapler": {"price": 11.00, "stock": 14},
"monitor stand": {"price": 42.99, "stock": 3},
"desk lamp": {"price": 27.50, "stock": 8},
}
def list_products() -> dict:
return {"products": sorted(PRODUCTS)}
def get_product(name: str) -> dict:
item = PRODUCTS.get(name.lower().strip())
if item is None:
return {"error": f"no product called {name!r}"}
return {"name": name, "price": item["price"], "stock": item["stock"]}
# The dispatch table: tool name -> Python function. Adding a tool is one
# schema entry and one dict entry; the loop below never changes.
DISPATCH = {"list_products": list_products, "get_product": get_product}
# endregion
TOOLS = [
{
"type": "function",
"name": "list_products",
"description": "List the names of every product in the store.",
"parameters": {"type": "object", "properties": {}},
},
{
"type": "function",
"name": "get_product",
"description": "Price and stock for one product, by name.",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
]
QUESTION = ("Which items under $30 are actually in stock, and what would "
"one of each of those cost together?")
# region: the-loop
# The agent loop. Each iteration: send the conversation, execute every
# function call the model asked for, append the results, repeat. When a
# response has no function calls, it's the answer. The range() is the
# safety rail — never ship an unbounded loop.
input_list = [{"role": "user", "content": QUESTION}]
for _ in range(10):
response = client.responses.create(
model="gpt-5.4-nano",
input=input_list,
tools=TOOLS,
)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
input_list += response.output # keep the model's tool-request turn
for call in calls:
args = json.loads(call.arguments)
print(f"tool: {call.name}({args})")
result = DISPATCH[call.name](**args)
input_list.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result),
})
# endregion
print(response.output_text)
The loop body is the lesson. Every iteration sends the full conversation,
collects every function_call in the reply — models batch several calls into
one turn routinely, which is why the inner for exists — executes each
through the dispatch dict, and appends the outputs. When a reply carries no
calls, that reply is the answer. And the range(10): an unbounded agent loop
is an outage waiting for a confused model to request the same tool forever.
Cap it, always.
Gemini
Same store, same loop, and by now the Gemini spelling is familiar: you grow a
list of Content turns by hand — the model's function-call turn, then a user
turn carrying every function_response part — and resend the whole thing.
"""Week 6 - Multi-tool calling and tool loops (Gemini).
Same store, same loop, Gemini spelling. The conversation is a list of
Content turns you grow by hand: the model's function-call turn, then a
user turn carrying every function_response part.
"""
import os
import sys
from dotenv import find_dotenv, load_dotenv
from google import genai
from google.genai import types
load_dotenv(find_dotenv())
if not os.environ.get("GEMINI_API_KEY"):
sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# region: the-tools
PRODUCTS = {
"notebook": {"price": 3.50, "stock": 120},
"pen": {"price": 1.25, "stock": 0},
"stapler": {"price": 11.00, "stock": 14},
"monitor stand": {"price": 42.99, "stock": 3},
"desk lamp": {"price": 27.50, "stock": 8},
}
def list_products() -> dict:
return {"products": sorted(PRODUCTS)}
def get_product(name: str) -> dict:
item = PRODUCTS.get(name.lower().strip())
if item is None:
return {"error": f"no product called {name!r}"}
return {"name": name, "price": item["price"], "stock": item["stock"]}
DISPATCH = {"list_products": list_products, "get_product": get_product}
# endregion
CONFIG = types.GenerateContentConfig(
tools=[types.Tool(function_declarations=[
types.FunctionDeclaration(
name="list_products",
description="List the names of every product in the store.",
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
),
types.FunctionDeclaration(
name="get_product",
description="Price and stock for one product, by name.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={"name": types.Schema(type=types.Type.STRING)},
required=["name"],
),
),
])],
)
QUESTION = ("Which items under $30 are actually in stock, and what would "
"one of each of those cost together?")
# region: the-loop
# Same loop shape as the OpenAI side: send, execute, append, repeat,
# bounded. Gemini batches several function calls into one turn routinely,
# so the inner for matters here too.
contents = [types.Content(role="user", parts=[types.Part(text=QUESTION)])]
for _ in range(10):
response = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=contents,
config=CONFIG,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
args = dict(fc.args)
print(f"tool: {fc.name}({args})")
result = DISPATCH[fc.name](**args)
parts.append(types.Part.from_function_response(name=fc.name, response=result))
contents.append(types.Content(role="user", parts=parts))
# endregion
print(response.text)
One habit to keep from this pair: tool errors go back as data, not as
exceptions. Return {"error": "no product called 'notebok'"} and the model
reads it, corrects the name, and retries — the loop self-heals. Raise instead,
and you've traded a recoverable model mistake for a 500.
Put it to work
Three docker-compose web apps under code/showcase/<slug>/, same drill:
bash bootstrap-secrets.sh, docker compose up --build,
http://localhost:3000. Each one needs a genuine chain — no single call
answers any question worth asking these demos.
Showcase 1 — CSV analyst
Ask questions about a quarter of order data the model never sees. It discovers
the schema with list_columns, then chains filter_rows and aggregate
calls until it has the number. That's the access pattern you want against a
real warehouse table that doesn't fit in a prompt — and a preview of why the
retrieval chapters later in the book work the way they do.
Showcase 2 — Home automation
A simulated five-room house and a conditional command: "turn off the lights in every unoccupied room." The model can't act blind — it has to read the state, decide which rooms qualify, and then write. The reply appends a change list computed in Python from the before-and-after state diff, because models sometimes misreport their own side effects — the report comes from the state, not from the model's memory of what it did.
Showcase 3 — Trip budgeter
"Four days in Tokyo and three in Paris, in pesos" takes five calls: two
city-cost lookups, an exchange rate, and calculator calls for the totals. The
calculator is an AST-whitelisted evaluator — numbers and four operators, no
eval — so the multi-step arithmetic models reliably fumble always runs in
Python. Ask about a city that isn't in the table and the error comes back as
data listing what is; watch the model recover.
All three run the same backend/main.py, dispatching on PROVIDER, so
PROVIDER=gemini docker compose up --build swaps SDKs without touching the
lesson. The design choice that keeps them honest is the bounded loop plus
errors-as-data: the model gets room to plan and recover, and it physically
can't run away.
Run it
The README in this folder has the Python version, the install line, the two
environment variables, and the exact commands for the basic examples and each
showcase. Keys come from the untracked .env at the course root, same as every
other week.
Takeaways
An agent is a while loop with a dispatch dict — hold onto that, because the industry will try to sell it back to you with a logo. What makes one production-worthy isn't the loop, it's the discipline around it: a hard iteration cap, tool errors returned as data the model can read and recover from, and side effects reported from computed state instead of the model's say-so. The model plans, your code executes and verifies. Next week the tools stop being toys: embeddings, and the road to retrieval.