Plan, Act, Observe: The Agent Loop in Code
The minimal agent loop is about 20 lines, and it works right up until the model does something you did not plan for. It loops forever. A tool throws and the whole thing crashes. It calls three tools at once and your loop only handled one.
This article turns that toy loop into one you could run, keeping the same shape, plan, act, observe, but adding the four things the minimal version left out: a hard step limit, dispatching more than one tool call per turn, catching each tool’s errors and handing them back to the model, and a clean way to stop.
Start with the safety rail
Before anything clever, cap the loop. The most common way an agent fails in the wild is that it never returns a final answer: it calls tools in circles, and every turn is another paid model call. Without a limit, that runs until you notice the bill.
So the outer loop is not while True. It counts, with for step in range(max_steps). When the count runs out, you return an explicit “hit the step limit” error. Treat this as a hard rail, not an edge case. Pick a number generous for the task (10 to 20 covers most things) and low enough that a runaway loop is cheap.
Why cap steps instead of trusting the model to stop?
The model has no idea how many turns it has taken. Each call sees the conversation so far, not a step counter, so it cannot decide “I have tried enough, give up.” Left alone, a confused model will retry the same failing tool or chase a goal it cannot reach, forever.
The step cap lives in your code, outside the model, which is exactly where a safety rail belongs. It is a request timeout for the loop: set it once, and it protects you no matter how the model behaves.
One turn: plan, act, observe
Each pass through the loop is one turn, and the three parts map cleanly to code.
Plan. Send the whole message list to the model and ask for its next move. It replies with a final answer or one or more tool calls.
Act. If it asked for tools, your code runs them. The model only names the tool and the arguments; running them is your code.
Observe. Each tool result goes back into the message list, and the loop comes around so the model can plan with the new information.
flowchart LR
START([Goal]) --> PLAN[Ask model:<br/>next move?]
PLAN --> CHECK{Tool calls?}
CHECK -->|no| DONE([Return answer])
CHECK -->|yes| ACT[Run each tool,<br/>catch errors]
ACT --> OBS[Append results<br/>to messages]
OBS --> PLAN
Dispatch one or more tool calls per turn
The toy loop assumed one tool per turn. Real models often ask for several at once. If the model wants the weather in three cities, it returns three tool calls, and running them in parallel beats three separate turns. So “act” is a loop over response.tool_calls, not a single call.
The important detail: every tool call needs a matching result appended before you go back to the model. Skip one and most APIs reject the next request, because the model asked a question and you left it unanswered. One call in, one result out, always paired.
Catch each tool’s errors and feed them back
This is the change that separates a demo from something usable. In the toy loop, a tool that throws crashes the whole agent. In production, tools fail constantly: the API times out, the query returns nothing, the argument was malformed. That is normal, and the agent should recover.
The move is counterintuitive: do not raise the error up to your code. Catch it, turn it into text, and hand it back to the model as the tool result. The model reads “the search API returned a 429, rate limited” the same way it reads a normal result, and decides what to do next: wait, try a different tool, or tell the user it could not finish.
The unknown-tool case matters too. Models sometimes hallucinate a tool that does not exist. Returning an error string instead of crashing lets the model correct itself on the next turn. Both cases show up in the full loop below.
Why feed errors to the model instead of retrying in code?
You can retry in code, and for a plain network blip you should. But most tool errors are not blips; they are signals about what to do differently. A “no results found” means the query was wrong and the model should rephrase it. A validation error means the arguments were bad and the model can fix them. Silently retrying the same broken call just burns attempts on the same failure. Retry the transient stuff in code; route the meaningful failures back through the loop.
The message list is the state
The loop above never stores anything in a database, a cache, or a variable named state. All of the agent’s memory lives in one place: the growing list of messages. Watch it grow over a two-tool task.
flowchart TB
U["user: the goal"] --> A1["assistant: call search"]
A1 --> T1["tool: search results"]
T1 --> A2["assistant: call fetch_page"]
A2 --> T2["tool: page contents"]
T2 --> A3["assistant: final answer"]
Every turn appends: the goal, the model’s tool-call request, your tool result, the next request, the next result, until a final answer with no tool calls. Because the model itself is stateless, this list is the only thing carrying context forward. Each turn you re-send the entire list; that is how the model “remembers” step 1 when it plans step 5.
This explains a lot. Persisting an agent means saving the message list. Resuming means loading it and calling again. Debugging a bad decision means reading the messages the model saw at that point. And “the context got too long” is a real limit: the list only grows, until it strains what the model tracks well or overflows the context window. Managing that list is most of the hard work in later articles.
A note on ReAct-style reasoning
You will see this pattern called ReAct, short for reason and act, from the paper that named it. The idea is small but useful: let the model think in plain text before it acts. Before calling search, it might write “I need current prices, so I will search first,” then make the call. That reasoning becomes part of the assistant message, so it carries forward like everything else.
You do not build ReAct as a separate mechanism; modern models reason between actions on their own when the task calls for it. The payoff is that the model’s plan is visible in the transcript, which makes debugging far easier than guessing why it picked a tool.
The improved loop, end to end
Still plan, act, observe, now with a step cap, multi-call dispatch, and per-tool error handling:
def run_agent(goal, tools, max_steps=15):
messages = [{"role": "user", "content": goal}]
for step in range(max_steps):
# PLAN: ask the model for its next move
response = model.call(messages, tools=tools)
messages.append(response.message)
# DONE: no tool calls means the model is finished
if not response.tool_calls:
return response.text
# ACT + OBSERVE: run every requested tool, errors included
for call in response.tool_calls:
fn = tools.get(call.name)
try:
result = str(fn(**call.arguments)) if fn \
else f"Error: no tool named {call.name!r}"
except Exception as e:
result = f"Error running {call.name}: {e}"
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
return "Stopped: hit the step limit before finishing."
That is a loop you can point at a real task. The stop condition is explicit (final answer or step limit), every tool call gets a paired result, and a failing tool feeds the model information instead of crashing the process.
Common beginner mistakes
while Truewith no cap. The number one way to burn tokens. Always bound the loop with a step count.- Handling only one tool call per turn. Models batch calls. Loop over
response.tool_calls, or the extra calls silently vanish. - Letting a tool exception escape. One failed tool should not kill the agent. Catch it and return the error as the result.
- Unpaired tool results. Every tool call needs a matching result appended before the next model call, or the API rejects it.
- Treating the message list as disposable. It is the entire state. Truncate it carelessly and the model forgets what it was doing.
Questions you will face in production
“Should I run the tool calls in a turn in parallel?” If they have no side effects and do not depend on each other, yes; parallel is faster and the model batched them on purpose. Run them sequentially when order matters or one writes data another reads. Either way the results go back in the same message list.
“What do I return when the step limit hits?” An explicit failure the caller can see, not a silent empty string. “Hit the step limit” signals the task was too hard, the tools were wrong, or the cap was too low. Log the full message list so you can read what happened.
“How big should the message list get before I worry?” Watch the token count, not the message count. When the list approaches the model’s context window, quality drops well before it overflows. Trimming or summarizing older turns is where state management gets interesting later in this curriculum.
What to remember
- The step cap is a hard safety rail in your code, not something the model can be trusted to enforce
- One turn is plan, act, observe; the loop repeats it until a final answer or the cap
- Models can ask for several tools per turn; dispatch all of them and pair each with a result
- Catch tool errors and feed them back as text so the model can recover instead of crashing
- The growing message list IS the agent’s state; persisting, resuming, and debugging all come back to it
What to study next
You have a loop that survives contact with real tools. The next question is where the tools come from and how to describe them so the model picks the right one: giving an agent tools covers defining tool schemas, writing descriptions the model can act on, and validating the arguments it generates before you run anything with side effects.
Further reading
- ReAct: Synergizing Reasoning and Acting in Language Models. The paper behind the reason-then-act pattern this loop uses.
- Anthropic: Tool use documentation. How tool calls and results are structured at the API level, including the pairing rule.
- Anthropic: Building effective agents. Practical guidance on loop design, stopping conditions, and when to keep it simple.
Where this article comes from. This is a synthesis of common practice in AI engineering as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.