Chapter 51 — The agent loop, spelled out
In one sentence
An agent is a while loop: ask the model what to do, do it, tell the model what
happened, repeat — until it says it's finished or you stop it.
The loop
+-----------------------------------------------------------+
| |
| 1. THINK send the conversation + tool list to the LLM |
| |
| 2. ACT if it returns a TOOL CALL: |
| YOUR CODE executes the tool |
| |
| 3. OBSERVE append the tool's result to the conversation |
| |
| 4. REPEAT ------------------------------------->-------+
|
| ...until the model returns a normal answer instead of a
| tool call, OR you hit a limit (steps, time, cost).
+-----------------------------------------------------------+
That's the whole idea. It's often called the ReAct pattern (Reasoning + Acting), because the model alternates between thinking and doing.
What it looks like as a conversation
USER: "What's the weather in Paris, and should I take a coat?"
MODEL: [tool_call: get_weather(city="Paris")] <-- step 1: ACT
YOUR CODE: runs get_weather("Paris") -> {"temp_c": 8, "rain": true}
YOU APPEND: [tool_result: {"temp_c": 8, "rain": true}] <-- step 2: OBSERVE
MODEL: "It's 8 C and raining in Paris -- yes, take a coat
and something waterproof." <-- done, no tool call
Notice: the model never runs anything. It emits a request to run something. Your code decides whether to honour it. That gap is where every guardrail in Chapter 60 lives.
The loop in ~40 lines
This is a real, working agent. Read it slowly; it's the heart of the Part.
import json
def run_agent(user_message, tools, tool_impls, client, model,
max_steps=10, max_cost_usd=0.50):
"""
tools: list of tool SCHEMAS (what the model sees)
tool_impls: dict of name -> python function (what actually runs)
"""
messages = [
{"role": "system", "content":
"You are a helpful assistant. Use tools when needed. "
"When you have the answer, reply normally without calling a tool."},
{"role": "user", "content": user_message},
]
spent = 0.0
for step in range(max_steps): # <-- HARD LIMIT
response = client.chat(model=model, messages=messages, tools=tools)
spent += estimate_cost(response) # <-- BUDGET LIMIT
if spent > max_cost_usd:
return "Stopped: cost limit reached."
messages.append(response.message)
# No tool call? The model is done.
if not response.tool_calls:
return response.message["content"]
# Otherwise: execute each requested tool
for call in response.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments)
print(f" step {step}: calling {name}({args})") # <-- OBSERVABILITY
if name not in tool_impls: # <-- ALLOWLIST
result = {"error": f"unknown tool {name}"}
else:
try:
result = tool_impls[name](**args)
except Exception as e:
result = {"error": str(e)} # <-- errors go BACK
# to the model
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result)[:4000], # <-- TRUNCATE
})
return "Stopped: step limit reached."
The five things in that code that aren't the loop
They look like details. They are the difference between a demo and something you can run in production.
| Line | Why it's there |
|---|---|
max_steps |
An agent will loop forever given the chance. Always cap it. |
max_cost_usd |
A loop that costs $0.01 a step can cost $500 overnight. Always cap it. |
if name not in tool_impls |
Never dispatch on a model-supplied string without an allowlist. |
except Exception -> result |
Errors go back to the model as an observation. It can then correct itself — that's a large part of why agents work at all. |
[:4000] truncation |
A tool returning a 2 MB file will blow your context window (Chapter 45) and your budget. |
Why does this work at all?
Because the model was trained on enormous amounts of text where people reason step by step, and post-trained (Chapter 34) on examples of using tools correctly. Each loop iteration is just next-token prediction (Chapter 31) — but the context now includes what actually happened last step, which grounds the next decision in reality rather than guesswork.
Practice (45 min) — build one yourself
Start from the loop above. Use any provider's API, or a local model that supports tool calling.
Step 1 — define two tools:
import ast, datetime, math, operator
OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.Pow: operator.pow, ast.USub: operator.neg,
}
def safe_calculate(expression):
def eval_node(node):
if isinstance(node, ast.Expression):
return eval_node(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](eval_node(node.left), eval_node(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
return OPS[type(node.op)](eval_node(node.operand))
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and
node.func.id == "sqrt" and len(node.args) == 1):
return math.sqrt(eval_node(node.args[0]))
raise ValueError("Only numbers, +, -, *, /, **, parentheses, and sqrt() are allowed.")
return eval_node(ast.parse(expression, mode="eval"))
tool_impls = {
"get_time": lambda timezone="UTC":
{"now": datetime.datetime.now(datetime.timezone.utc).isoformat()},
"calculate": lambda expression:
{"result": safe_calculate(expression)},
}
tools = [
{"type": "function", "function": {
"name": "get_time",
"description": "Get the current UTC time.",
"parameters": {"type": "object", "properties": {}}}},
{"type": "function", "function": {
"name": "calculate",
"description": "Evaluate a simple arithmetic expression.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"]}}},
]
Step 2 — run it and watch the loop:
print(run_agent("What time is it, and what is 17 * 23 + sqrt(144)?",
tools, tool_impls, client, model))
You should see two tool calls printed, then a final answer.
Step 3 — experiments (this is where the learning is):
- Ask something needing no tools ("Who wrote Hamlet?"). The loop exits after one iteration. Good.
- Make
calculatealways throw an exception. Watch the model receive the error and try something else. This is the single most illuminating experiment in the chapter. - Set
max_steps=1and ask a two-step question. Watch it fail gracefully. - Remove the
systemprompt line about replying normally when done. Does it still stop cleanly? - Add a
printof the fullmessageslist at the end. Read it. That growing list is the agent's entire mind — there's nothing else.
Common confusions
- "Where does the agent's 'thinking' live?" In the
messageslist, and nowhere else. Every step re-sends the whole conversation. There is no hidden state. - "Does the model run my code?" No. It emits a structured request. Your code chooses to run it. Keep that boundary sacred (Chapter 61).
- "Why does it sometimes call the same tool twice?" Usually because the first result didn't answer the question, or your tool description is ambiguous. Tool descriptions are prompts — write them carefully.
- "How does it know when to stop?" It stops when it produces a normal message
instead of a tool call. Your
max_stepsis the backstop for when it doesn't.
Check yourself
- Describe the four stages of the agent loop.
- Who actually executes a tool — the model or your code?
- Why do you send tool errors back to the model?
- Name three limits every agent loop must have.
- Where is the agent's state stored?
Further reading
- Paper (readable): "ReAct: Synergizing Reasoning and Acting in Language Models" (Yao et al., arXiv:2210.03629) — the origin of this loop.
- Article: "Building Effective Agents" — Anthropic. The "autonomous agent" section describes exactly this loop.
- Code: read the source of a small agent library (e.g.
smolagentsfrom Hugging Face) — small enough to read in an afternoon, and it demystifies frameworks.