# AI Agent Loop in Python: Build a ReAct Agent From Scratch

> Build an AI agent loop in Python from scratch: a framework-free ReAct agent that thinks, calls a tool, observes the result, and repeats until the job is done.

*Source: https://www.infowok.com/ai-agents-from-scratch-python-part-3/ · Sukhveer Kaur · Published June 19, 2026*

---

> **Series: AI Agents from Scratch in Python**
> This is Part 3. So far: [Part 1](/ai-agents-from-scratch-python-part-1/) made your first LLM
> call, and [Part 2](/ai-agents-from-scratch-python-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 shows up on dev.to, Hacker News, and half the engineering blogs, repeated again and again. 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.

<Prerequisites>

- You can define a tool and run a single tool call — that's [Part 2](/ai-agents-from-scratch-python-part-2/)
- You can read a `while` loop, `try`/`except`, and `for`/`else` — the [Part 0 primer](/ai-agents-from-scratch-python-part-0/) covers control flow
- An OpenAI API key

</Prerequisites>

<KeyTakeaways>

- **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.

</KeyTakeaways>

## 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](/ai-agents-from-scratch-python-part-1/) you have **one LLM call**: send a list of messages, get a reply. From [Part 2](/ai-agents-from-scratch-python-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.

![The agent loop showing Thought, Action, and Observation as a cycle around the model, with a branch out to a final answer when no tool is needed](./ai-agents-from-scratch-python-part-3-loop.svg)

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](https://arxiv.org/abs/2210.03629)). 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.

```python
from openai import OpenAI
import json

client = 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 function
tools_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].message
    messages.append(reply)            # remember what the model just said

    if not reply.tool_calls:          # no tool wanted -> the loop is done
        print(reply.content)
        break

    for call in reply.tool_calls:     # run every tool the model asked for
        fn = 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.

![Five-step flowchart of the agent loop: send messages, check for a tool request, run the tool, append the observation, repeat, and stop on a final answer](./ai-agents-from-scratch-python-part-3-flow.svg)

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.

<Callout type="key">
If the model decides what happens next, it's an agent. If your code decides, it's a workflow. That single distinction is the whole point of the loop.
</Callout>

A quick contrast makes it concrete. Imagine the hardcoded version of the same task:

```python
# 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](/what-are-ai-agents-complete-guide-2026/) 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:

```python
for call in reply.tool_calls:
    args = json.loads(call.function.arguments)
    try:
        fn = TOOLS[call.function.name]      # KeyError if the name is invented
        output = fn(**args)
    except Exception as error:
        output = f"Tool error: {error}"     # hand the problem back to the model
    messages.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.

<Callout type="warning" title="Always cap the loop">
Without a max-steps guard, a stuck agent can loop forever and burn real money — I once watched a 2-cent task become a 40-cent runaway. Treat the cap as required, not optional.
</Callout>

<Callout type="tip" title="Debug by printing each step">
A single `print(step, reply.tool_calls)` at the top of the loop turns the agent from a black box into a readable trace — you watch it think, act, and observe in real time, and instantly see a wrong tool, a repeated call, or an early exit.
</Callout>

## 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:

```python
for step in range(5):
    ...                                   # the loop body from above
else:                                     # 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](/langgraph-vs-crewai-vs-autogen-2026/), 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](/ai-agents-from-scratch-python-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)](/langgraph-vs-crewai-vs-autogen-2026/)** — see the loop you just wrote hiding inside all three frameworks.

<NextSteps>

- **Need the earlier pieces?** [Part 2](/ai-agents-from-scratch-python-part-2/) builds tool calling; the [Part 0 primer](/ai-agents-from-scratch-python-part-0/) covers the Python.
- **Next in this series:** [Part 4 — give the agent memory](/ai-agents-from-scratch-python-part-4/) so it remembers across turns.
- **See it in a framework:** [LangGraph vs CrewAI vs AutoGen](/langgraph-vs-crewai-vs-autogen-2026/) shows this loop inside all three.

</NextSteps>
