Series: AI Agents from Scratch in Python This is Part 3. So far: Part 1 made your first LLM call, and Part 2 let the model call one of your functions. Here we wrap that single call in an AI agent loop so the model can take several steps on its own. If you can run the Part 2 code, you have everything you need.
The most-liked comment under almost every “AI agent” tutorial is the same five words: an agent is a loop. It is the top reply on this month’s breakout agent video, and dev.to, Hacker News, and half the engineering blogs keep repeating it. They are right — and in this post you’ll write that AI agent loop yourself, in plain Python, with no framework.
By the end you’ll have a working AI agent loop that thinks, picks a tool, runs it, reads the result, and keeps going until it can answer.
Part 2 ran a tool exactly once, by hand. A real agent does it again and again, deciding for itself when to stop. That one change — a while loop around the call you already know — is the whole leap from chatbot to agent. Let’s build it.
- An agent is a loop: the model thinks, calls a tool, reads the result, and repeats until it answers.
- The line that makes it an agent is where the model — not your code — decides the next step.
- Three guards keep it safe: a max-steps cap, a try/except around tools, and a forced final answer.
Recap: The Two Pieces You Already Have
You are not starting from zero. The loop is just your Part 1 and Part 2 code, repeated.
From Part 1 you have one LLM call: send a list of messages, get a reply. From Part 2 you have one tool call: describe a function, let the model request it, run it, and hand the result back. An AI agent loop is what you get when you stop doing that once and start doing it in a cycle.
That cycle has a name. It is the ReAct pattern — short for Reasoning and Acting — introduced by Yao and colleagues in 2022 (the original paper is here). The model produces a Thought about what to do, takes an Action by calling a tool, sees the Observation that comes back, and repeats.
The classic version parsed all of that out of plain text. Today’s models expose the Action through native tool calling, so you get the same loop with far less glue code — which is exactly why we can build it in about thirty lines.
The AI Agent Loop, in About 30 Lines
Here is the core of every agent. Read it once, then we will walk through the three moving parts.
from openai import OpenAIimport jsonclient = OpenAI()def search(query: str) -> str:# Pretend lookup. Real code would call a search API.return "Mount Everest is 8,849 metres tall."TOOLS = {"search": search} # name -> real functiontools_schema = [{"type": "function","function": {"name": "search","description": "Look up a fact you do not already know.","parameters": {"type": "object","properties": {"query": {"type": "string"}},"required": ["query"],},},}]messages = [{"role": "user", "content": "How tall is Everest, in feet?"}]for step in range(5): # max-steps guard (more on this below)response = client.chat.completions.create(model="gpt-5.4-mini", messages=messages, tools=tools_schema,)reply = response.choices[0].messagemessages.append(reply) # remember what the model just saidif not reply.tool_calls: # no tool wanted -> the loop is doneprint(reply.content)breakfor call in reply.tool_calls: # run every tool the model asked forfn = TOOLS[call.function.name]args = json.loads(call.function.arguments)result = fn(**args)messages.append({"role": "tool", "tool_call_id": call.id, "content": result,})
Run that and the model does something it could not do in Part 2: it reaches for search on its own, reads back “8,849 metres,” and then converts the answer to feet in its final reply — about 29,032 feet. You never told it to search and never told it to convert. It decided both.
The three moving parts are small. The for loop is the agent — it lets the model take more than one step. The if not reply.tool_calls check is the exit — when the model stops asking for tools, it has an answer. And appending every message (both the model’s reply and each tool result) is the memory of this run: each pass through the loop, the model sees everything that happened before. Lose that, and the agent forgets what it just learned.
Where It Stops Being a Workflow and Becomes an Agent
This is the question readers actually ask: are we building a real agent here, or just a script with extra steps? The honest answer is in one line of that code.
In a normal program, you decide the order: call the API, then parse, then format. The control flow lives in your code. In the loop above, the line if not reply.tool_calls hands that decision to the model. The model — not your code — chooses whether to act again or to stop. That is the line where a workflow becomes an agent.
A quick contrast makes it concrete. Imagine the hardcoded version of the same task:
# A workflow: YOU decide the steps, every time, in order.facts = search("height of Mount Everest")answer = convert_to_feet(facts)print(answer)
That works, but only for this exact question. Ask it “how tall is Everest and K2?” and it breaks, because the steps are fixed. The agent loop handles both, because the model can choose to search twice before answering.
When people say an agent is a loop where the model decides control flow, this is what they mean — and it is the definition VentureBeat and others settled on across 2026. If you want the full conceptual breakdown, the complete guide to AI agents covers it; here we care that you can see it in your own code.
Parsing Actions Safely and Knowing When to Stop
The bare loop works, but two things will bite you in real use: a tool that throws, and a loop that never ends. Both have simple fixes.
First, never let one tool crash the whole agent. The model fills in the arguments, and it can get them wrong — a missing field, or a function name that does not exist. Wrap the dispatch so a failure becomes a message the model can recover from, not a stack trace:
for call in reply.tool_calls:args = json.loads(call.function.arguments)try:fn = TOOLS[call.function.name] # KeyError if the name is inventedoutput = fn(**args)except Exception as error:output = f"Tool error: {error}" # hand the problem back to the modelmessages.append({"role": "tool", "tool_call_id": call.id, "content": output,})
When the model sees "Tool error: ..." as an observation, it usually corrects itself on the next pass — picks a real tool, or fixes the argument. That recovery is only possible because the result goes back into messages.
Second, the stop condition. The loop ends naturally when reply.tool_calls is empty — the model has nothing left to do, so it answered. The range(5) around the loop is the safety net for when that never happens, which the next section explains.
Common Failures (and the Fixes)
Every from-scratch agent hits the same three problems. Here is each one and the line that fixes it.
1. The infinite loop. The model keeps calling tools and never gives a final answer, burning tokens on every pass. The fix is the for step in range(5) cap you already saw. Pick a number that fits the task — most simple agents finish in two or three steps — and force an answer if you hit it:
for step in range(5):... # the loop body from aboveelse: # runs only if we never `break`messages.append({"role": "user","content": "Give your best final answer now, no tools."})final = client.chat.completions.create(model="gpt-5.4-mini", messages=messages)print(final.choices[0].message.content)
That for/else is real Python: the else runs only when the loop finishes without a break. It guarantees the user always gets an answer, even when the agent runs out of steps.
2. Hallucinated tools. The model asks for a function you never defined. Without a guard, TOOLS[call.function.name] raises KeyError and the program dies. The try/except from the previous section turns that into a recoverable error message instead — the single most common reason a beginner’s agent crashes.
3. No final answer. Sometimes the model calls a tool and then stops mid-thought because of a token limit, leaving no plain reply. Raise max_tokens for the final step, or rely on the for/else fallback above to ask one more time. Between the step cap, the try/except, and the forced final answer, the loop is now hard to break.
One more thing worth saying plainly: this is the loop that every framework wraps. When you eventually compare LangGraph, CrewAI, and AutoGen, you will recognise this exact cycle under all three — they add retries, tracing, and state, but the heartbeat is what you just wrote.
Quick Recap
The whole agent loop, in five points:
- The loop sends messages to the model, runs any tool it asks for, appends the result, and repeats.
- It stops when the model replies with no tool call.
- It’s an agent because the model, not your code, picks the next step.
- Guard against runaways with a max-steps cap and a forced final answer.
- Guard against crashes by wrapping every tool call in try/except.
Conclusion
You built a real agent from scratch: a loop that lets the model think, call a tool, read the result, and decide its own next step — about thirty lines, no framework, with guards for the failures that actually happen. The moment your code stopped dictating the steps and let the model choose, it became an agent. That is the whole idea, and now it is yours.
There is one honest limit. Your agent forgets everything the instant the loop ends — start a new question and it has no idea what came before. Giving it memory, so it remembers across turns, is exactly what Part 4 tackles.
When you ran the loop, what was the first tool your agent reached for — a search, a calculator, a database query? Tell me in the comments; the choices people make here are always interesting.
Read next: LangGraph vs CrewAI vs AutoGen (2026) — see the loop you just wrote hiding inside all three frameworks.





