The agent loop: ReAct from scratch.

An agent is not a model. It is a while-loop wrapped around one - and the loop is where all the interesting engineering lives.

Strip away the framework marketing and an LLM agent is embarrassingly small: a loop that asks a model what to do, does it, feeds the result back, and repeats until the model says it is done. The 2022 ReAct paper (Reason + Act) gave this loop its name and its shape. Everything else - tool registries, routers, memory - is scaffolding around this one control-flow skeleton.

In this article we build that skeleton in plain Python. No framework, one small dataclass, and a loop you can read in a single sitting. By the end you will have a working agent and, more importantly, a mental model precise enough that every framework you touch afterward looks like a variation on the same theme.

The shape of the loop

ReAct interleaves three moves: the model reasons about what to do, emits an action, and observes the result of that action. Then it reasons again with the observation in hand. The loop terminates when the model emits a special “finish” action instead of a tool call.

We explore the use of LLMs to generate both reasoning traces and task-specific actions in an interleaved manner.

Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models (2022)

Those two moves and the observation between them are the whole cycle. It is worth naming them as first-class ideas, because every later pattern is really about one of these three:

Reason
Thought
The model plans its next move in natural language, out loud, in the transcript.
Act
Action
It emits a structured tool call the environment knows how to execute.
Observe
Observation
The tool's result is fed back verbatim, becoming input to the next Reason step.

Written as pseudocode, the whole thing fits on a napkin:

messages = [system_prompt, user_question]
loop:
    reply = model(messages)
    if reply is a final answer:
        return reply.text
    else:
        result = run_tool(reply.tool, reply.args)
        messages.append(reply)
        messages.append(observation(result))

The subtlety is entirely in the words if reply is a final answer. How does the model tell us it wants to call a tool versus stop? That contract - the action protocol - is the single most important design decision in the whole agent.

An action protocol you can parse

Modern model APIs give you native tool-calling, where the model returns structured JSON. But to understand what is happening underneath, it is worth building the older, text-based protocol once by hand. We ask the model to emit one of two line-prefixed forms:

Thought: I should look up the population of Tokyo.
Action: search("population of Tokyo")

or, when it is ready to answer:

Thought: I now know the answer.
Final: About 14 million in the city proper.

A tiny parser turns that text into a typed decision. We use a dataclass so the rest of the code never touches raw strings:

import re
from dataclasses import dataclass


@dataclass
class Action:
    tool: str
    arg: str


@dataclass
class Finish:
    answer: str


Decision = Action | Finish

_ACTION_RE = re.compile(r'Action:\s*(\w+)\((.*)\)\s*$', re.MULTILINE)
_FINISH_RE = re.compile(r'Final:\s*(.*)', re.DOTALL)


def parse(reply: str) -> Decision:
    if m := _FINISH_RE.search(reply):
        return Finish(answer=m.group(1).strip())
    if m := _ACTION_RE.search(reply):
        arg = m.group(2).strip().strip('"\'')
        return Action(tool=m.group(1), arg=arg)
    # The model broke protocol. Treat the whole reply as the answer
    # rather than crashing - a real agent must survive a sloppy model.
    return Finish(answer=reply.strip())

Notice the last branch. The model will break protocol. It will forget the prefix, wrap the action in prose, or hallucinate a tool. A parser that raises on the first malformed line gives you an agent that dies on contact with reality. A parser that degrades gracefully gives you one that limps forward, and limping forward is usually what you want.

The protocol is the API. Every design choice downstream - retries, validation, tool schemas - is a consequence of how the model signals intent. Get the action protocol right and the rest of the agent is plumbing.

The tools

A tool is any Python callable that takes a string and returns a string. That is deliberately the least interesting definition possible, because the agent should not care what a tool is, only that it can call one and get text back. We keep a plain dict as a registry for now:

def search(query: str) -> str:
    # Stand-in for a real web search. In production this hits an API.
    facts = {
        "population of Tokyo": "The population of Tokyo is about 14 million.",
        "height of Everest": "Mount Everest is 8,849 metres tall.",
    }
    return facts.get(query, f"No result found for {query!r}.")


def calc(expression: str) -> str:
    # Arithmetic only. Never hand a model eval() over real code.
    allowed = set("0123456789+-*/(). ")
    if not set(expression) <= allowed:
        return "Error: only basic arithmetic is allowed."
    try:
        return str(eval(expression, {"__builtins__": {}}))
    except Exception as e:
        return f"Error: {e}"


TOOLS = {"search": search, "calc": calc}

The calc tool is a small lesson in itself. The model will happily ask you to evaluate arbitrary expressions, so the tool - not the model - is responsible for refusing dangerous input. A tool is a trust boundary. Validate at the boundary, because nothing upstream will.

The loop itself

Now we assemble the pieces. The model function here is a placeholder; swap in a real API call and nothing else changes. The important part is the control flow, which is genuinely just a bounded for loop:

def run_agent(question: str, model, max_steps: int = 6) -> str:
    system = (
        "Answer the question. You may use tools by writing:\n"
        "  Action: tool_name(argument)\n"
        "Available tools: search(query), calc(expression).\n"
        "When you know the answer, write: Final: <answer>"
    )
    transcript = f"{system}\n\nQuestion: {question}\n"

    for step in range(max_steps):
        reply = model(transcript)
        transcript += reply
        decision = parse(reply)

        match decision:
            case Finish(answer):
                return answer
            case Action(tool, arg):
                fn = TOOLS.get(tool)
                obs = fn(arg) if fn else f"Error: unknown tool {tool!r}."
                transcript += f"\nObservation: {obs}\n"

    return "Stopped: reached the step limit without a final answer."

Three details earn their place here, and they are the difference between a toy and something you would let run unattended.

First, max_steps is not optional. A model that never emits Final: will loop forever, and a loop that calls a paid API forever is a way to turn a bug into an invoice. The bound is a hard safety rail, not a tuning knob.

Second, an unknown tool is an observation, not an exception. When the model hallucinates weather("Tokyo"), we feed back Error: unknown tool 'weather' and let it recover on the next turn. This is the same graceful-degradation instinct as the parser: the environment reports errors to the model, in band, so the model can react to them. Crashing throws away the model’s ability to self-correct.

Third, the transcript is the entire state. There is no hidden memory, no database, no vector store. Everything the agent knows is in that growing string. This is worth internalising, because when an agent misbehaves, the transcript is both the bug report and the crime scene. Log it.

Watching it run

Before the code, step through the loop yourself. The figure below plays back exactly the trace our scripted model produces: one Thought, one Action, the Observation that comes back, a final Thought, and the answer. Watch the transcript grow, because that growing transcript is the agent.

Figure - The ReAct loop, one turn at a time
Turn 0
Press Step to advance the loop. Each model call emits a Thought and then either an Action (which the environment runs, returning an Observation) or a Final answer that ends the loop. The transcript on the left is the agent's entire state.

Here is the scripted “model” that drives it, so you can see the same loop turn without an API key. It plays back a fixed ReAct trace, which is exactly what a well-behaved model would produce:

def scripted_model(transcript: str) -> str:
    # Returns the next block a real model would generate, based on how
    # far the transcript has progressed.
    if "Observation:" not in transcript:
        return "Thought: I need the population.\nAction: search(population of Tokyo)"
    if transcript.count("Observation:") == 1:
        return "Thought: I have it.\nFinal: About 14 million people."
    return "Final: (unexpected)"


print(run_agent("How many people live in Tokyo?", scripted_model))
# -> About 14 million people.

Trace it by hand once. The loop calls the model, gets a search action, runs the tool, appends Observation: The population of Tokyo is about 14 million., calls the model again, gets a Final:, and returns. Two model calls, one tool call, one answer. That is the whole dance.

An agent is a loop, not a model. The model is a stateless next-token function. The agent is the loop, the parser, the tool boundary, and the step limit wrapped around it. When people say an agent is “smart,” they usually mean the loop was engineered well.

What we deliberately left out

This agent is honest but naive. It has one flat tool namespace, no retries on malformed output, no way to run tools in parallel, and no memory beyond the transcript. Each of those gaps is a design pattern waiting to happen:

  • The dict of tools becomes a decorator-driven registry that introspects function signatures.
  • Choosing which set of tools or sub-agent handles a request becomes a routing problem.
  • Sharing state between several agents becomes the Blackboard pattern.
  • And stopping the loop from spinning on the same tool forever becomes a cycle-prevention problem - which is subtle enough to deserve its own article.

The thread running through all of them is this: the loop stays simple, and the sophistication accretes in the pieces around it. Keep the skeleton readable and you can always see what your agent is actually doing. That readability is not a nicety. When an agent does something surprising in production, the flat, boring loop you can hold in your head is the only thing that lets you find out why.

Work with us

Production AI, with guardrails.

Start with a fixed-scope AI Workflow Audit. We map the opportunity and quote a build.

Start a Discovery Sprint