What Is the Agent Loop? A Visual Guide
The agent loop explained without the jargon: a labeled skeleton you can run today, plus the one detail every tutorial skips. See exactly what turns a model call into an agent.
def agent_loop(goal, tools, model, max_steps=8):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": goal},
]
for step in range(max_steps):
reply = model.call(messages, tools=tools)
messages.append(reply.as_message())
if reply.tool_calls:
for call in reply.tool_calls:
result = tools[call.name](**call.args)
messages.append(tool_result(call.id, result))
else:
return reply.text # model answered in prose = done
return "Stopped: hit step limit before finishing."Most first-time agent builders wire up a single tool call, watch the model use it once, and call it an agent. It isn't. Roughly nine out of ten "my first agent" repos I've reviewed contain no loop at all — just one request, one tool, one response. The thing that actually makes an agent an agent is the loop wrapped around the model, and it's the part tutorials sprint past. So here's the agent loop explained the way I wish someone had drawn it for me: every moving part labeled, plus a skeleton you can paste and run in the next five minutes.
An agent loop is a while that keeps calling the model until a goal is met or a limit trips. That's the whole idea. The power isn't in any single call — it's that the model sees the result of its last action before it picks the next one. Take that feedback away and you don't have an agent. You have a very expensive autocomplete.
Quick-Start (Copy This Right Now)
[object Object], ,[object Object],(,[object Object],):
messages = [
{,[object Object],: ,[object Object],, ,[object Object],: SYSTEM_PROMPT},
{,[object Object],: ,[object Object],, ,[object Object],: goal},
]
,[object Object], step ,[object Object], ,[object Object],(max_steps):
reply = model.call(messages, tools=tools)
messages.append(reply.as_message())
,[object Object], reply.tool_calls:
,[object Object], call ,[object Object], reply.tool_calls:
result = tools[call.name](**call.args)
messages.append(tool_result(call.,[object Object],, result))
,[object Object],:
,[object Object], reply.text ,[object Object],
,[object Object], ,[object Object],What this does: runs the model, executes whatever tool it asks for, appends the result to the running transcript, and repeats — until the model replies in plain text or the step counter runs out.
That's a complete agent in fourteen lines. Everything else — planning, memory, reflection — is a variation on this shape.
Understanding the Variables
Four things flow through every turn of the loop, and naming them makes debugging far easier.
State is messages: the growing transcript the model reads on every call. Action is the tool call the model emits. Observation is the tool's return value, appended back as a tool_result. And the stop signal is whatever ends the loop — a prose reply, a step cap, or an explicit finish tool.
The observation is the piece people forget. If you run a tool and don't feed its output back into messages, the model is deciding step three while blind to what step two returned. It hallucinates a result and marches on. I've debugged this exact bug in production three times; it always looks like "the model is dumb" and it's always a missing append.
⚡ Pro tip: Log the four variables on every iteration — step number, chosen tool, args, and observation length. When an agent misbehaves, that four-column trace tells you within seconds whether the model chose badly or the tool returned junk.
Step-by-Step: The Agent Loop Explained
Walk one full turn, slowly.
First, the model reads the entire messages transcript and decides: answer now, or call a tool? Second, if it calls a tool, your code — not the model — executes it. The model never runs code; it only requests it. Third, you capture the return value and append it as an observation. Fourth, the loop repeats, and now the model plans with that new fact in hand.
[object Object],
reply = model.call(messages, tools=tools) ,[object Object],
,[object Object], reply.tool_calls:
call = reply.tool_calls[,[object Object],]
obs = tools[call.name](**call.args) ,[object Object],
messages.append(tool_result(call.,[object Object],, obs)) ,[object Object],
,[object Object],What this does: shows the decide → act → observe cycle for a single iteration, making clear that execution happens in your runtime while the model only chooses.
Here's the counterintuitive part. The model doesn't "remember" anything between turns. Each call is stateless. The only reason the agent seems to remember is that you keep handing it the full transcript. Memory in an agent loop is an illusion produced by re-sending history. Once that clicks, context-window problems and state bugs stop being mysterious.
⚡ Pro tip: Give the model an explicit finish(answer) tool instead of relying on it to "reply in prose when done." Prose-vs-tool-call is a fuzzy stop condition; an explicit finish tool is a crisp one, and it makes your loop's exit deterministic.
Pro-Level Variations
Once the base loop runs, three upgrades cover most real needs.
A support engineer building a ticket-triage agent adds a routing tool and a finish tool, then caps steps at four — triage should be fast and cheap. A data analyst running a research agent raises the cap to fifteen and adds a scratchpad the model writes intermediate findings to, so long chains don't blow the context window. A DevOps lead wiring an incident-response agent adds a human-approval gate before any destructive tool fires, so the loop pauses for a thumbs-up before it restarts a service.
A QA engineer testing a flaky checkout flow wires an agent that retries a failing step up to three times before escalating — the same loop, with a retry counter folded into the observation. Same skeleton, four behaviors. That's the point of understanding the shape: you stop copying whole frameworks and start adjusting a handful of knobs.
DESTRUCTIVE = {,[object Object],, ,[object Object],, ,[object Object],}
,[object Object], call.name ,[object Object], DESTRUCTIVE ,[object Object], ,[object Object], human_approved(call):
obs = ,[object Object],
,[object Object],:
obs = tools[call.name](**call.args)What this does: intercepts high-risk tool calls and returns a blocked observation instead of executing, so the model has to route around the action or wait — without you rewriting the loop.
⚡ Pro tip: Put the step number in the system prompt on each turn ("This is step 3 of 8"). Models pace themselves better when they can see the budget shrinking, and they're far likelier to call finish before you force-stop them.
Troubleshooting Common Issues
Three failures cover most of what goes wrong early.
The agent repeats the same tool call forever? Its last observation didn't change its plan — usually because the observation was empty or unparseable. Print the observation; you'll find the tool returned None or a stack trace the model couldn't read.
The agent quits too early? Your stop condition is too loose. A model that replies "Let me check that for you" in prose looks done to a naive if not reply.tool_calls check. Use the explicit finish tool instead.
The agent runs out of context around step ten? You're re-sending a transcript that grows every turn. That's expected — it's why compaction and scratchpads exist — but it means the loop needs a summarization step, not a bigger model.
And the agent calls a tool that doesn't exist? The model invented a plausible-sounding name. Return a clear observation — No such tool: fetch_orders. Available: track, eta — and it usually corrects itself on the very next turn. A raw exception or silence teaches it nothing; a readable error teaches it everything.
⚠️ Common mistake: Treating max_steps as a safety net you never expect to hit. It's not a net — it's a budget. If your agent regularly finishes on the last allowed step, it isn't succeeding; it's being cut off mid-thought and returning whatever it had. Watch how often you hit the cap. A healthy agent finishes with steps to spare; one that lives at the ceiling needs either better tools or a rethink of the goal.
⚡ Pro tip: When an agent stalls, replay its exact transcript through the model once, by hand, and read what it "sees." Nine times out of ten the bug is obvious the moment you look at the same context the model looked at — a truncated observation, a tool error dressed as data, a contradictory instruction.
Your Turn
Paste the quick-start loop, give it two toy tools — say a calculator and a fake web-search that returns canned text — and a goal that needs both. Watch the trace. You'll see the model call one tool, read the observation, then call the other. That handoff, decision to observation to next decision, is the agent loop explained in a single run you can watch live.
⚡ Pro tip: Keep your first agent's tools fake and deterministic. Real APIs add latency and flakiness that mask loop bugs. Once the loop is provably correct against canned tools, swap in the real ones — now any new failure is the tool's fault, not the loop's, and you know exactly where to look.
The moment that click happens — that the loop, not the model, is what you're really building — the whole field gets simpler. Frameworks stop looking like magic and start looking like opinionated wrappers around these same four variables. You'll read one, recognize the skeleton underneath, and know exactly which knob it's turning.
Once your loop works, you'll build a dozen variations of it, and you'll want the good versions of each prompt — the system prompt, the finish-tool description, the reflection step — somewhere you can reuse them instead of rewriting from memory. That's exactly what I keep in PromptABCD: a saved, versioned set of the loop prompts that actually shipped, so the next agent starts from a known-good baseline instead of a blank file.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
