Chapter 3: The Agent Loop
Why This Matters
By the end of this chapter, you'll have built a real, working agent in under 60 lines of Python. Not a toy that pretends β an actual agent that perceives a task, thinks about what to do, calls a tool, looks at the result, and decides whether it's done or needs to keep going. This is the heartbeat from Chapter 1, made of code. Everything in the rest of the book is a refinement of what you build here.
Recap: The Four Steps
From Chapter 1, the agent loop is four steps: perceive β think β act β observe. Let's translate each into something we can code.
Here's the key insight: the messages list is the agent's entire world. The user's request goes into it (perceive). The LLM reads it and responds (think). If the response says "call a tool," you run that tool (act). You put the tool's result back into the messages list (observe). Then you call the LLM again β and it thinks with the new information. The loop is just: call the brain, check what it said, do it if needed, repeat.
The Pieces We Need
Before we write the loop, let's line up the parts. We need three things:
1. The brain
The LLM call from Chapter 2. Same function, same messages list. Nothing new here β we just call it inside a loop now.
2. A tool the agent can call
For this first agent, let's give it one simple tool: a calculator. Why? Because LLMs are famously bad at math. If you ask "what's 17 Γ 24?", the model might guess 408 (right) or 418 (wrong) β it's predicting text, not computing. A calculator tool fixes this. The agent thinks "I should use the calculator," calls it, and gets the exact answer.
3. A way for the LLM to tell us it wants a tool
This is the trickiest piece, and it's worth slowing down for. The LLM can't call a function β it only outputs text. So how does it tell us "I want to use the calculator"? We have to agree on a format.
The cleanest way β and the one most agent frameworks use β is function calling. You tell the LLM, as part of the API call, "here are the tools you have." The LLM can then respond with a special kind of message that says "call this function with these arguments," instead of plain text. Your code reads that, runs the function, and feeds the result back.
TOOL: calculator ARGS: 17*24 and then parsing that text with a regex. It works, but it's fragile β the LLM might misspell "TOOL" or add extra text. Function calling is the clean, reliable version of the same idea. We'll use it throughout the book.Writing the Loop
Now the main event. Let's build the whole agent. We'll go piece by piece, then put it together.
Step 1: Describe the tool to the LLM
Step 2: The loop itself
Here's the whole agent. Read it slowly. We'll walk through it right after.
Reading the Loop, Line by Line
Let's trace what happens when we call run_agent("What's 17 times 24, then add 100?")
Do you see it? The agent broke the problem into two calculations, called the tool twice, and then decided on its own that it was done. Nobody hardcoded "first multiply, then add." The LLM figured out the order from the question. That's the autonomy from Chapter 1, happening in real code.
Trace the loop for this input: "What's the capital of France?"
How many turns does it take? Does it call the calculator tool? Why or why not? What does the agent output? Write down your trace before reading on.
Answer: One turn. The LLM sees the question, sees the calculate tool, and decides calculate is irrelevant β this isn't math. It responds with plain text ("Paris") and no tool call. The if not msg.tool_calls check fires, and we print and return. The loop ran once.
The Stopping Condition
There are two ways the loop stops:
1. The LLM responds without a tool call. This is the
normal "I'm done" signal. When the brain thinks it has the answer, it
just says the answer β no tool needed. We detect this with
if not msg.tool_calls and return.
2. We hit max_turns. This is the safety net.
If the LLM gets stuck in a loop β calling the same tool over and over,
or never deciding it's done β the for loop runs out and we
stop. Without this, a buggy agent could run forever, burning tokens and
money.
max_turns. Always. An agent without a turn
limit is a while True loop that costs money every
iteration. In production you'll see this as max_iterations,
recursion_limit, or a timeout β but the idea is the same:
no agent runs forever on your dime.
Where People Come Unstuck
Mistake #1: Forgetting to add the tool result back
If you run the tool but forget to append the result to
messages, the LLM never sees what happened. On the next turn
it's flying blind β it asked for a calculation but got no answer back.
It'll either ask again (loop!) or hallucinate a result. The
OBSERVE step β putting the result back β is not optional.
It's the whole point.
Mistake #2: Forgetting to add the LLM's tool-call message back
This one is subtle. When the LLM responds with a tool call, you must add
its message to the conversation before you add the tool
result. The line messages.append(msg) is easy to miss. If
you skip it, the API will error β because a "tool" role
message has to follow the assistant message that requested it. The
conversation has to be consistent: the LLM's request, then the tool's
reply, in order.
Mistake #3: No max_turns
We said it once, we'll say it again. Without a turn limit, a confused agent loops forever. Set it. Even 10 is generous for most tasks.
msg.tool_calls β it's a list. The LLM might say "I need to calculate 17*24 AND 50+100" in the same response. We run both, add both results, and the LLM sees both on the next turn. This is called parallel tool calling, and it's a nice speed boost when the tools are independent.calculate("seventeen times twenty-four") instead of calculate("17 * 24"). Your tool either handles it gracefully (return an error message) or crashes (and you catch the exception). Either way, the error goes back into messages as the tool result, and the LLM sees "Error: ..." and tries again with better arguments. The loop is self-correcting β one of its best features.
Our agent has one tool: a calculator. But the loop doesn't care how
many tools there are. Think about what happens if you add a second
tool β say, get_weather(city) that returns the current
weather.
What would you need to change in the code? (Hint: very little.) How would the LLM know which tool to use? What if the user asks "Should I bring a jacket to Lisbon tomorrow?" β would the agent use the calculator, the weather tool, both, or neither? Trace the loop in your head.
Chapter Summary
- The agent loop is four steps made of code: perceive (user message into
messages), think (call the LLM), act (run the tool it asked for), observe (put the result back intomessages). - The messages list is the agent's entire world. Every turn, the LLM reads the whole thing fresh. The loop just keeps adding to it and calling the brain again.
- Function calling is how the LLM tells us it wants a tool: it responds with a structured tool-call message instead of plain text. Our code reads that, runs the function, and feeds the result back.
- The LLM never runs anything. It requests tool calls; your code executes them. The brain suggests; the body acts.
- The loop stops when the LLM responds without a tool call (it's done) or when you hit max_turns (the safety net).
- An agent with one tool is already a real agent. Adding more tools is the same pattern β describe them, handle them in the
if name == ...block, and the loop does the rest.
Add a Second Tool. Take the agent from this chapter
and extend it with a get_weather(city: str) tool. For
testing, you can fake it β just return a hardcoded string like
"Lisbon: 22Β°C, sunny". You don't need a real weather API.
1. Write the tool function.
2. Add its description to the tools list.
3. Add an elif name == "get_weather" branch in the loop.
4. Test it with: "What's 5 + 3, and what's the weather in Lisbon?"
Watch the agent decide on its own which tool to use for which part of the question β and whether it does them in one turn or two. That's autonomy, in code you wrote, tonight.