The agent loop
Lesson 4 of 6
4The agent loop
The big pictureEvery agent, whatever framework built it, is the same loop: send the messages to the model; if it asks for a tool, run it and append the result; repeat until it answers. Write this loop yourself once, with a step cap, spin detection and a trace of every step, and you will understand every agent framework you meet afterwards.
What the picture shows: The same cycle repeats: the question about order 48213 took two tool rounds and answered on the third. The cap in the middle guarantees the loop ends whatever the model does.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
An agent repeats four steps: think, pick a tool, see its result, then decide whether it is done or needs another step. Like any new employee, you set a limit: "no more than ten steps", so it does not spin in an endless loop and burn the budget.
The loop: send messages with tool definitions; if the model asks for a tool, your program runs it, appends the result to the messages and sends again; otherwise that is the final answer. You add a cap on steps and tokens and log every step to debug behaviour later.
Before You Start
🔧Tools as an interface
You know how a tool is defined and how to write an error message the model understands.
🔌The MCP protocol
You have seen how the tool list reaches the client and how a tool run is requested.
The Core Idea
Every agent is the same loop. In this lesson you write it by hand, add spin detection, then the trace, and then put a real model in place of the scripted one.
1The loop and its step cap
The loop is four moves: send the messages; if the model asks for a tool, run it; append its result; repeat until a final answer or the step cap.
Look at the picture: the messages grow with every step. Each tool result is appended to them, and that is how the model "knows" what happened.
import json
def get_order_status(order_id):
return {"48213": {"status": "delayed", "reason": "weather"}}.get(order_id, {"error": "not found"})
def get_refund_policy(reason):
return {"policy": "Delays over 3 days due to weather: free shipping refund."}
TOOLS = {"get_order_status": get_order_status, "get_refund_policy": get_refund_policy}
def scripted_model(messages):
"""Stands in for the model so the loop runs here. A real model decides from the messages."""
tool_results = [m for m in messages if m["role"] == "tool"]
if len(tool_results) == 0:
return {"tool": "get_order_status", "args": {"order_id": "48213"}}
if len(tool_results) == 1:
return {"tool": "get_refund_policy", "args": {"reason": "weather"}}
return {"answer": "Your order 48213 is delayed by weather; you qualify for a shipping refund."}
def run_agent(question, max_steps=5):
messages = [{"role": "user", "content": question}]
for step in range(1, max_steps + 1):
action = scripted_model(messages)
if "answer" in action:
print(f"step {step}: answer -> {action['answer']}")
return action["answer"]
result = TOOLS[action["tool"]](**action["args"])
print(f"step {step}: {action['tool']}({json.dumps(action['args'])}) -> {json.dumps(result)}")
messages.append({"role": "assistant", "content": json.dumps(action)})
messages.append({"role": "tool", "content": json.dumps(result)})
print(f"stopped: reached {max_steps} steps without an answer")
return None
run_agent("Why is my order 48213 late, and do I get anything back?")
The model here is a pre-written function that decides the steps in place of a real model, so the loop runs in the browser without an API key. The loop code itself does not change when you put a real model in its place. The first step fetched the status and the delay's reason, the second used the reason to fetch the policy, and the third answered. Notice each tool result is appended to the messages; that is how the model "knows" what happened. And max_steps=5 guarantees the loop ends whatever the model does.
while True until the model answers. It only takes a tool returning an error the model does not understand for it to spin hundreds of times, each round dearer than the last. The step cap is the first line you write in any agent.2Detecting a spin
A spin is the same call repeated with the same arguments, usually because the tool does not give the model what it needs. The step cap stops it eventually, but only after wasted steps.
Look at the picture: the loop counts each identical call. When the count reaches 3, more than max_repeats = 2, it stops and hands over to a person.
A search tool that finds nothing, and a model that repeats the same search. Without detection it would spin until the cap.
import json
def search(query):
return {"results": []} # the tool keeps finding nothing
def stuck_model(messages):
return {"tool": "search", "args": {"query": "refund policy weather"}}
def run_agent(model, max_steps=8, max_repeats=2):
messages, seen = [], {}
for step in range(1, max_steps + 1):
action = model(messages)
key = json.dumps(action, sort_keys=True)
seen[key] = seen.get(key, 0) + 1
if seen[key] > max_repeats:
return f"stopped at step {step}: same call repeated {max_repeats} times, handing over to a person"
result = search(**action["args"])
print(f"step {step}: search -> {result}")
messages.append({"role": "tool", "content": json.dumps(result)})
return f"stopped: step cap of {max_steps} reached"
print(run_agent(stuck_model))
The loop stores each call as JSON with sorted keys and counts repeats. On the third repeat it stops and hands over to a person, instead of burning five more steps for nothing. In a real system, also return a message to the model saying "this search found nothing twice; try another phrasing or ask the user" before stopping.
3The trace
The trace records every step: what the model asked for, with which arguments, what the tool returned, and how long and how much it took. Without it you cannot diagnose any failure.
Look at the picture: one line per step with five fields. It is the first thing you read when the agent goes wrong.
4The loop with a real model
With a real model one line changes: scripted_model becomes an API call with the tool list. The reply is either text, the final answer, or a tool request with its name and arguments.
def run_agent(client, question, tools, impl, max_steps=10):
messages = [{"role": "user", "content": question}]
for step in range(max_steps):
reply = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
msg = reply.choices[0].message
if not msg.tool_calls:
return msg.content # the final answer
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = impl[call.function.name](**args)
log_step(step, call.function.name, args, result, reply.usage)
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
return "I could not finish this within the step limit; a person will follow up."
Exactly the same structure. The difference is the model may request several tools in one reply, so we loop over them, and each result is tied to its request id tool_call_id. And log_step writes the trace with the token usage of each step.
How It Works, Step by Step: one round of the agent loop
Send all the messages so far, with the tool list, to the model.
If it replies with text, that is the final answer and the loop ends.
If it asks for a tool, run it and record its name, arguments, result and time in the trace.
Append the request and its result to the messages, count repeated calls, and go back to the start unless the cap is reached.
Worked example
A customer asks why their order is late and whether they get anything back. The agent needs two steps before answering: the order status, then the refund policy for the delay's reason.
Try it without code
Myth vs Reality
| Myth | Reality |
|---|---|
| The model remembers tool results between requests. | It has no memory between requests. All it "knows" is what your loop appends to the messages and sends again. |
| Agent frameworks do something more complex than a loop. | At the heart of every framework is the same loop: send, run, append, repeat. The layers around it hide it, they do not change it. |
| If the agent hits the cap, raise the cap. | Raising it hides the problem and multiplies the cost. Read the trace first: spinning on a tool? an unclear error? a missing tool? |
Try it yourself
How does the model "know" the result of the tool it requested in the previous step?
The model has no memory between requests. All it "knows" is what is in the messages you send, which is why the history grows with each step.
What should happen when the loop reaches the step cap?
The cap is a safety net, not an obstacle. The user needs to know their request was not completed, and you need the trace to learn why.
Quick review cards
Check Your Understanding
The agent hits the step cap in 20% of conversations. What is the first step?
Read the traces of those conversations; do not raise the cap. Look for the pattern: does it spin on one tool? Does a tool return an unclear error? Does it need a missing tool and try to assemble it from others? Raising the cap hides the problem and multiplies the cost.
In the spinning example, why are calls turned into JSON with sorted keys before counting them?
So the same call with the same arguments always gets one key whatever the order of its arguments, and repeats are counted correctly.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs1
Done with this lesson?
Sign in to save your progress.