Skip to content

Building a ReAct Agent from Scratch

Ask an agent "Who won the 2025 Edinburgh Marathon and what is the approximate population of their home country?" — a question that needs two lookups, the second depending on the first. Underneath, the model doesn't call anything directly: it writes out a line of plain text, Action: search[2025 Edinburgh Marathon winner], and your own code has to notice that line and pull the tool name and the search term out of it with a regular expression — a pattern-matching rule like re.search(r"Action:\s*(\w+)\[(.+?)\]", text). If the model's wording drifts even slightly from the expected format, that pattern-match fails, silently, and the agent stalls or invents its next move instead of asking for help.

That's the version this lesson builds. Not because it's how you should ship a production agent — it isn't, and by the end you'll see exactly why not — but because building it by hand is the only way the reliability of native tool calling (where the model returns a structured object instead of text you have to parse) stops being a syntax difference and starts being a fix for a failure you've personally caused.

Before you start

Prerequisite: Lesson 14, From Reactive Loops to Strategic Reasoning — you should already have ReAct placed on the seven-technique taxonomy as the "reason, then act, then observe" scaffold. This lesson does not re-argue where ReAct sits; it builds it. After this lesson, you can: build a Thought/Action/Observation loop by hand with regex-based dispatch, explain the specific failure mode that makes text-parsed tool calling fragile, and choose correctly between text parsing and native function calling for a given agent.

Every framework that offers tools= and a tool_choice parameter is hiding the decision above — it used to be your problem, solved by hand, the way the regex above solves it.

Two different models do the two jobs in this lesson, and the split is deliberate: google/gemma-2-2b-it, a small model, runs the direct-vs-Chain-of-Thought comparison from lesson 14 first, on the same Edinburgh train-timing question used there — and the measured result is that CoT uses 2–5x more completion tokens than answering directly, on that identical question. That's the concrete cost side of "more thinking" that lesson 16 will come back to. The ReAct loop itself then runs on a larger model, Qwen/Qwen3-235B-A22B-Instruct-2507, because the multi-step tool-use trace needs a model capable of reliably following the Thought/Action/Observation format across several turns.

What ReAct actually is, underneath the framework

Yao et al. (ICLR 2023) proposed ReAct as a simple loop: the model produces a Thought (its reasoning about what to do next), an Action (a tool call in a fixed text format), waits for an Observation (the tool's result), and repeats until it has enough information to produce a Final Answer. The pattern is intuitive. The mechanism, in the paper and in most early implementations, is not intuitive at all — it's regex.

The ReAct loop: reason, act, observe, repeat until Final Answer

Here is the exact line that does the work:

python
match = re.search(r"Action:\s*(\w+)\[(.+?)\]", text)

The model is instructed, in its system prompt, to write text like Action: search[Edinburgh Marathon 2025 winner]. Your code then runs that regex against the raw completion string, hoping the model followed the format exactly. If it did, match.group(1) is the tool name and match.group(2) is the argument. If the model added a stray character, used the wrong brackets, or just answered in prose instead — the regex returns None, and your loop has to decide what to do with a model that didn't follow instructions.

Define the system prompt and the tool registry

The system prompt is the entire contract. It tells the model the exact format to use — Thought:, then Action: tool_name[argument], then wait for Observation: — and lists the available tools by name. Two tools are enough to prove the pattern: a mock web search and a calculator that evaluates a restricted character set before calling eval.

Run the loop and parse each completion

Each iteration calls the model with stop=["Observation:"] so it never fabricates its own tool result, checks for Final Answer: first, and otherwise runs the regex above against the completion text. A failed match doesn't crash the loop — it appends a corrective message ("Please provide an Action or Final Answer") and tries again, which is itself evidence of how much defensive code the fragile format demands.

Dispatch to the matched tool and feed the result back

Once the regex succeeds, the tool name is looked up in a plain Python dictionary (TOOLS = {"search": mock_web_search, "calculator": calculator}). The result — or an error string, if the tool itself throws — becomes the next Observation: message, and the loop continues with that observation appended to the conversation.

The trace that shows the pattern working

Run the loop against the marathon question from the top of this lesson, and here's the full trace:

Files changed
react_agent.py

The agent's first Thought recognizes it needs the marathon result before anything else, and issues Action: search[2025 Edinburgh Marathon winner]. The mock search tool returns: Kenenisa Bekele, from Ethiopia, in a time of 2:05:32. The second Thought recognizes the country is now known and issues Action: search[Ethiopia population 2025], which returns an estimated 130 million. The third Thought has both facts and produces a Final Answer combining them.

Two things are worth noticing in that trace. First, there was no hallucination — the agent never guessed a name or a population figure, because the prompt's rule ("never invent facts, only use information from Observations") combined with the tool-grounded loop structurally prevents it from answering without a matching Observation in context. Second, the two searches were sequential by necessity, not by choice — the second query literally could not be formed until the first observation returned Bekele's nationality. That dependency is invisible in a two-step trace. It becomes the whole problem once a task has ten steps instead of two, which is where this lesson ends.

Terminal
$
python react_agent.py

Where the regex actually breaks

The loop above works when the model cooperates. The honest failure mode is what happens when it doesn't. A malformed completion — extra whitespace inside the brackets, a nested bracket in the search query itself, the model deciding to explain its action in prose before emitting the Action: line in a format your regex didn't anticipate — and re.search returns None. Nothing raises an exception. Nothing logs a clear error. The loop just doesn't know what tool to call, and the fallback path (asking the model to try again) burns a full round-trip on every silent miss. At scale, across many concurrent agent runs, this is not a rare edge case — it's a background failure rate baked into using free text as a wire protocol between two programs that both need to agree on a format neither one enforces.

Text parsing fails silently by construction

The danger isn't that regex parsing sometimes breaks. It's that when it breaks, nothing tells you until you notice the agent looping or stalling. There is no schema validation step, because there was never a schema — just a string convention the model was asked, not required, to follow.

The fix that already existed: native tool calling

Compare the dispatch code side by side. Text-parsed ReAct pulls a tool call out of message.content with a regex. Native function calling — the Nebius Token Factory pattern, following the same shape OpenAI and Anthropic use — has the model return a structured tool_calls object directly, with the tool name and arguments already parsed and typed by the API itself:

python
# Native tool calling (Nebius pattern)
resp = client.chat.completions.create(
    model="Qwen/Qwen3-235B-A22B-Instruct-2507",
    messages=messages,
    tools=tools,         # JSON schemas
    tool_choice="auto"   # model decides when to call
)
# Model returns structured tool_calls, not text to parse
if resp.choices[0].message.tool_calls:
    for tc in resp.choices[0].message.tool_calls:
        args = json.loads(tc.function.arguments)
        result = TOOL_MAP[tc.function.name](**args)
ApproachParsingReliabilityBest for
Text-parsedRegex on model outputFragileTeaching, legacy models
Native function callingStructured tool_calls objectRobustProduction, Nebius API

The reliability difference here isn't a vague "it's more modern" claim — it's the specific failure mode from the section above, structurally removed. The model can't emit a malformed tool_calls object the way it can emit malformed free text, because the API layer is validating the JSON schema on the way out, not your regex on the way in. This is the entire argument for building the fragile version first: without having watched re.search return None on a real completion, "native tool calling is more reliable" reads as a marketing claim. Having built the regex loop, it reads as a specific bug you just personally avoided.

Quick check — A colleague says 'ReAct is just the tool-calling pattern, so text parsing and native function calling are basically the same thing with different syntax.' What's wrong with that claim?

What this doesn't fix

Even swapped to native tool calling, the loop built in this lesson is still linear — it decides what to do next only after seeing the previous Observation, with no plan spanning more than one step ahead. Push it past three or four dependent lookups and that starts to matter. Walk through a 10-step version of the Edinburgh itinerary from lesson 14 and a linear agent breaks in a specific, predictable place: at step 6, it discovers the dinner venue needs 48 hours' advance booking — a constraint nothing upstream in the loop was tracking — and by step 7, the context window carrying the full conversation is filling up fast enough to threaten every earlier constraint the agent needs to remember.

ReAct (Linear): Step 1 → Step 2 → Step 3 → ✗ Step 4 → Step 5 → crash

Neither the regex fix nor the native-calling fix touches that problem, because it isn't a parsing problem — it's the same "locally correct, globally incoherent" gap lesson 14 named. Fixing it means giving the agent a plan it can revise, not a more reliable way to call one tool at a time.

Continue to Lesson 16

See what a reasoning model's thinking tokens actually are, why they fail in specific predictable ways, and how the Planner-Executor split fixes the linear trap this lesson ended on.

Have a question about this lesson?

Reply here and it goes straight to Rod. Same as replying to one of his emails.