Course ES

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.