↑ Contents Chapter 3 of 12

Chapter 3: The Agent Loop

Maya is back. She's been thinking about the Lisbon trip agent from Chapter 1, and she has a question that won't leave her alone. "I get the loop," she tells her friend TomΓ‘s over coffee. "Perceive, think, act, observe. But... how does it actually work? Like, in code? What's running the loop? What tells the brain to think again? What stops it from looping forever?" TomΓ‘s grins. "That's the whole chapter. By tonight you'll have written one yourself." Maya looks at him like he's crazy. "In one evening?" "In one evening. It's shorter than you think. The magic isn't in any single line β€” it's in the shape. Once you see the shape, you can't unsee it."

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.

PERCEIVE The user's message goes into messages[] THINK Call the LLM with messages[] ACT If the LLM wants a tool, run it OBSERVE Put the tool result back into messages[]
Same loop. Now each step is a line of code. The messages list is the glue.

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.

# Our tool: a simple calculator def calculate(expression: str) -> str: """Evaluate a math expression and return the result.""" try: result = eval(expression) # safe for our demo; never eval untrusted input in production return str(result) except Exception as e: return f"Error: {e}"
// A tool is just a Python function. It takes a string, returns a string. That's the contract.

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.

How function calling works Your code calls the LLM + describes the tools available LLM responds either text OR a tool call Your code runs the tool and sends result back LLM gets the result and continues thinking
The LLM never runs the tool. It asks your code to. Your code runs it and hands the result back.
Note The LLM never executes anything. It requests a tool call by outputting a structured message. Your code decides whether to run it, runs it, and sends the result back. The brain suggests; the body acts. This separation is what makes agents safe β€” you always have the final say over what actually runs.
There Are No Dumb Questions
Q: What if the LLM doesn't have function calling? Can I still build an agent?
A: Yes. Before function calling existed, people built agents by asking the LLM to output a special format like 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.
Q: Does the LLM "know" what the tool does? How does it decide to use it?
A: You describe the tool when you make the API call β€” its name, what it does, and what arguments it takes. The LLM reads that description and decides, based on the user's request, whether the tool is relevant. If the user asks "what's 17 Γ— 24?", the LLM sees the calculator tool and thinks "that's a math question, I should use it." If the user asks "what's the capital of France?", it doesn't.

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

# Tell the LLM what tools it has tools = [ { "type": "function", "function": { "name": "calculate", "description": "Evaluate a math expression. Use for any arithmetic.", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "The math expression, e.g. '17 * 24'" } }, "required": ["expression"] } } } ]
// The description is what the LLM reads to decide WHEN to use the tool. Write it clearly.

Step 2: The loop itself

Here's the whole agent. Read it slowly. We'll walk through it right after.

from openai import OpenAI import json client = OpenAI() def calculate(expression: str) -> str: try: return str(eval(expression)) except Exception as e: return f"Error: {e}" def run_agent(user_message: str, max_turns: int = 10): messages = [ {"role": "system", "content": "You are a helpful assistant. Use the calculate tool for any math."}, {"role": "user", "content": user_message}, ] for turn in range(max_turns): # THINK: ask the brain response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, temperature=0, ) msg = response.choices[0].message # If the LLM didn't ask for a tool, it's done β€” print and stop if not msg.tool_calls: print("Agent:", msg.content) return msg.content # The LLM wants to call a tool. Add its request to the conversation. messages.append(msg) # ACT + OBSERVE: run each tool it asked for for tool_call in msg.tool_calls: name = tool_call.function.name args = json.loads(tool_call.function.arguments) if name == "calculate": result = calculate(**args) else: result = f"Unknown tool: {name}" print(f" [tool] {name}({args}) β†’ {result}") # OBSERVE: put the result back into the conversation messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result, }) print("Stopped: hit max turns.")
// The whole agent. Read it once, then we'll break it down.

Reading the Loop, Line by Line

Let's trace what happens when we call run_agent("What's 17 times 24, then add 100?")

# Turn 1 PERCEIVE messages = [system, user: "What's 17 times 24, then add 100?"] THINK LLM sees the math, sees the calculate tool. It responds with a tool_call: calculate("17 * 24") (no text response β€” it wants to act first) ACT We run calculate("17 * 24") β†’ "408" OBSERVE We add to messages: {role: "tool", content: "408"} # Turn 2 β€” the loop runs again with the new information THINK LLM sees: original question + tool result "408". It thinks: "408 + 100 = 508. I should calculate that too." It responds with a tool_call: calculate("408 + 100") ACT We run calculate("408 + 100") β†’ "508" OBSERVE We add to messages: {role: "tool", content: "508"} # Turn 3 THINK LLM sees: question + 408 + 508. It thinks: "I have the answer. 17 Γ— 24 = 408, plus 100 = 508." It responds with plain text β€” NO tool call. DONE No tool_calls β†’ we print the answer and return. # Agent: "17 Γ— 24 = 408, plus 100 = 508."
// Three turns. The loop ran three times. The agent decided when to act and when to stop.

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.

Sharpen your pencil

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.

Watch it! Always set a 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.

There Are No Dumb Questions
Q: Can the LLM call multiple tools in one turn?
A: Yes! That's why we loop over 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.
Q: What if the LLM calls a tool with wrong arguments?
A: It happens. The LLM might send 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.
Brain Power

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 into messages).
  • 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.
Chapter Challenge

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.

← Previous Next β†’