Part 12 showed the shape of the loop to make a point about why agents exist. This post writes the whole thing out, including the parts that only matter once you run it.
By the end you have a working agent in about forty lines.
The round trip has five steps
One tool call is a conversation with five moves in it, and three of them are yours.
- You send the user's message plus the tool definitions.
- The model replies with a tool call: a name and a set of arguments.
- You execute the function and get a result.
- You send everything back: the whole conversation so far, plus that result.
- The model answers, using what came back.
Five steps. Steps 1, 3 and 4 are yours; the model only ever produces text.
Step 3 is the one worth stopping on, because of what it says about step 2.
The model never executes anything. It cannot reach your filesystem, call your API or run your function. What it produces at step 2 is a textual specification: the name of a tool and some arguments, as text. Your code reads that text, decides whether to honour it, and does the work.
That single fact is where every control in this series lives. The model proposes; your code disposes.
The five things you have to build
Working backwards from those steps:
- Tool definitions, so the model knows what exists.
- The tools themselves, as ordinary functions.
- A way to send both to the model.
- Something that turns a tool call into an actual function call.
- A way to put the result back in the conversation so the model can continue.
Posts 13 and 14 covered 1 and 2. The rest is this post.
The toolbox
You need a lookup from a tool name to a callable. The neat version writes itself.
TOOLS = [read_tickets, search_web, calculate]
tool_box = {tool.__name__: tool for tool in TOOLS}
tool_definitions = [to_tool_definition(tool) for tool in TOOLS]
Three lines, one list. Add a function to TOOLS and it becomes available to the agent, with its
schema generated from its own signature. Nothing to register twice, so nothing can drift out of sync.
The __name__ key matters: it is the same string the model will send back, because it came from the
schema you generated from that same attribute.
Executing a tool call
The model hands you a name and a JSON string. Turning that into a call is short.
import json
def execute_tool(tool_box: dict, tool_call) -> str:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments) # the model sends JSON as a string
return tool_box[name](**args)
Three things happen there and each one deserves a look.
tool_call.function.arguments arrives as a string, not a dict. The model produced JSON as text
and you parse it, which means a malformed generation surfaces here as a JSONDecodeError.
tool_box[name] is a dictionary lookup on a name the model produced. A model that invents a tool name
raises KeyError here.
**args unpacks straight into your function, so a missing required argument raises TypeError.
All three are handled below. Notice that each is a normal Python error at a normal place, which is exactly what you want: the failure lands in your code, where you can see it.
The control loop
Now the whole thing.
def run_agent(system_prompt: str, question: str, max_steps: int = 10) -> str:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": question},
]
for step in range(max_steps):
reply = client.chat(
model=MODEL,
messages=messages,
tools=tool_definitions,
)
# No tool call means the model has an answer. Done.
if not reply.tool_calls:
return reply.content
messages.append(reply) # 1. what it asked for
for call in reply.tool_calls: # 2. models can ask for several at once
try:
result = execute_tool(tool_box, call)
except Exception as e:
result = f"Tool failed: {type(e).__name__}: {e}"
messages.append({ # 3. what came back
"role": "tool",
"tool_call_id": call.id, # 4. which call this answers
"content": str(result),
})
return "Stopped: reached the step limit without an answer."
Read the four numbered points, because each is a thing the short version in part 12 left out.
Appending the model's own message. The reply containing the tool call goes back into the list before the result does. Skip it and the model sees a result for a request it has no record of making.
Several calls per reply. A model can ask for three tools in one turn, so the inner loop is a loop.
Serial execution is fine to start; these are independent, so asyncio.gather applies here exactly as
it did for batching.
Errors become results. A failed tool returns a string describing the failure instead of crashing the agent. The model reads it and can retry with a different argument or explain the problem. This is the error-message discipline from post 14, applied at the loop level.
tool_call_id. When a reply contains multiple calls, this is what pairs each result with its
request. Omit it with more than one call in flight and the model has to guess which is which.
Watching it run
The ticket question, with the loop printing each step.
step 0 tool_use read_tickets(quarter="2026-Q2")
-> "password_reset: 118 tickets, 6.2 min avg\nbilling: 74 ..."
step 1 tool_use search_web(query="input token price per million current")
-> "Pricing page: <current rate> per 1M input tokens ..."
step 2 tool_use calculate(expression="118 * 6.2 / 60")
-> "12.19"
step 3 answer "Password resets consumed about 12.2 staff-hours last quarter..."
Four passes. Nothing in run_agent said four. The model asked for what it needed, in an order it
decided, and stopped when it had enough.
That is the whole thing. An agent is this loop plus tools worth calling.
The four things that break it
Runaway loops. A model can keep calling tools forever, especially when a tool keeps returning
something unhelpful. max_steps is the seatbelt, and hitting it is a signal to read the transcript
rather than to raise the limit.
Growing context. Every tool result stays in messages for the rest of the run. A ten-step run
carries nine results on the final call, which is why post 14 argued for small, shaped returns.
Malformed arguments. json.loads fails on a truncated or malformed generation. Catch it, return
the failure as a tool result, and the model usually corrects itself on the retry.
Invented tool names. A KeyError on tool_box[name] means the model asked for something that
does not exist. The same handler catches it, and the message tells the model what it actually has.
| Failure | Where it surfaces | What the loop does |
|---|---|---|
| Never finishes | max_steps exhausted | Returns a stop message you can see |
| Context grows too large | Provider error, or cost | Shape returns; trim old results |
| Malformed JSON arguments | json.loads | Returns the error as a tool result |
| Tool name does not exist | tool_box[name] | Returns the error as a tool result |
Every failure lands as a normal Python exception in your code, then goes back as a message.
What to take from this
- The model never executes anything. It emits a name and arguments as text, and your code decides whether to honour them.
- The loop is short: send, check for tool calls, execute, append, repeat until there are none.
- Build the toolbox from one list so the callable and its schema cannot drift apart, and turn every tool failure into a message the model can read.
Forty lines and a list of functions is a working agent. The next problem is that writing those functions for every service you care about does not scale.