Skip to content

2. ReAct from scratch

Intermediate · 13 min read

ReAct (Reason + Act, Yao et al., 2022) is the pattern behind most agents: the model alternates Thought (reasoning about what to do), Action (a tool call) and Observation (the tool's result) until it can give a Final Answer. Building it once by hand, with no framework, is the best way to understand every agent framework.

This page builds a weather agent that answers "Will it rain in Bengaluru today, and do I need an umbrella?". The "LLM" is scripted so it runs offline and shows every step, including a realistic mistake it has to recover from.

2.1 The format

Thought: what I should do next
Action: the tool to call
Action Input: the tool's input
Observation: (the tool's result — written by YOUR code, never by the model)
... repeat ...
Thought: I can answer now
Final Answer: the answer for the user

Interleaving reasoning with actions does two things: the reasoning picks better actions, and real observations keep the reasoning grounded, instead of the model guessing facts it could look up.

2.2 Tools and the system prompt

The agent knows tools only by name and description. A dictionary maps each name to the function that runs it:

import json
import re

# Offline stand-ins for the real Open-Meteo APIs (see 2.6 for the live version)
CITIES = {"bengaluru": {"name": "Bengaluru", "latitude": 12.97, "longitude": 77.59}}
FORECAST = {(12.97, 77.59): {"temperature_c": 24.1,
                             "rain_chance_by_hour": {"14:00": 20, "16:00": 65, "18:00": 80, "20:00": 55}}}

def geocode(city: str) -> dict:
    found = CITIES.get(city.lower())
    if not found:
        raise ValueError(f"city not found: {city!r}")
    return found

def get_weather(latitude: float, longitude: float) -> dict:
    return FORECAST[(round(latitude, 2), round(longitude, 2))]

TOOLS = {
    "geocode_city": lambda inp: geocode(inp.strip()),
    "get_current_weather": lambda inp: get_weather(**json.loads(inp)),
}

SYSTEM_PROMPT = """You are a ReAct agent that answers weather questions.

Tools:
geocode_city: Look up a city's latitude/longitude. Input: a city name, e.g. Paris
get_current_weather: Current weather and hourly rain chance. Input: JSON like {"latitude": 48.85, "longitude": 2.35}

Respond in exactly this format, ONE step per turn:
Thought: <your reasoning>
Action: <one of [geocode_city, get_current_weather]>
Action Input: <input for the tool>

When you can answer:
Thought: <final reasoning>
Final Answer: <answer in plain, friendly English>

Never write an Observation yourself."""
print(f"{len(TOOLS)} tools registered: {', '.join(TOOLS)}")
Output
2 tools registered: geocode_city, get_current_weather

2.3 The model (scripted)

A real model is called with stop=["Observation:"], so it stops right where your code should insert the tool result. Here, the replies are scripted, exactly as a real model produced them, including one mistake: invalid JSON.

SCRIPT = [
    "Thought: I need Bengaluru's coordinates before I can get the weather.\n"
    "Action: geocode_city\nAction Input: Bengaluru",
    "Thought: Now I can fetch the weather for those coordinates.\n"
    "Action: get_current_weather\nAction Input: {latitude: 12.97, longitude: 77.59}",       # not valid JSON
    "Thought: My input wasn't valid JSON. I'll quote the keys and try again.\n"
    'Action: get_current_weather\nAction Input: {"latitude": 12.97, "longitude": 77.59}',
    "Thought: The rain chance rises to 65% at 4 pm and 80% at 6 pm. That's likely rain this evening.\n"
    "Final Answer: Yes, rain is likely in Bengaluru this evening: about 65% around 4 pm and 80% around 6 pm. "
    "Take an umbrella if you'll be out after 3 pm.",
]

def call_llm(messages: list[dict]) -> str:
    """Stand-in for client.chat.completions.create(..., stop=["Observation:"])."""
    turn = sum(1 for m in messages if m["role"] == "assistant")
    return SCRIPT[turn]

2.4 The loop

Parsing pulls the action out of free text. Everything that can go wrong (no action, unknown tool, a tool that raises) becomes an Observation the model can read and react to, instead of crashing the agent.

def parse_action(text: str):
    action = re.search(r"Action:\s*(.+)", text)
    action_input = re.search(r"Action Input:\s*(.+)", text, re.DOTALL)
    if not action:
        return None, None
    return action.group(1).strip(), (action_input.group(1).strip() if action_input else "")

def parse_final_answer(text: str):
    match = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL)
    return match.group(1).strip() if match else None

def run_agent(question: str, max_steps: int = 6):
    messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": question}]
    for step in range(1, max_steps + 1):
        reply = call_llm(messages)
        messages.append({"role": "assistant", "content": reply})
        print(f"--- step {step} ---\n{reply}")

        final = parse_final_answer(reply)
        if final:
            return final
        action, action_input = parse_action(reply)
        if not action:
            observation = "Error: reply had neither an Action nor a Final Answer. Use the required format."
        elif action not in TOOLS:
            observation = f"Error: unknown tool {action!r}. Valid tools: {list(TOOLS)}"
        else:
            try:
                observation = json.dumps(TOOLS[action](action_input))
            except Exception as err:                       # tool errors go back to the model
                observation = f"Error running {action}: {type(err).__name__}: {err}"
        print(f"Observation: {observation}")
        messages.append({"role": "user", "content": f"Observation: {observation}"})
    return "Sorry, I couldn't finish within the step limit."

answer = run_agent("Will it rain in Bengaluru today, and do I need an umbrella?")
print("\nFINAL ANSWER:", answer)
Output
--- step 1 ---
Thought: I need Bengaluru's coordinates before I can get the weather.
Action: geocode_city
Action Input: Bengaluru
Observation: {"name": "Bengaluru", "latitude": 12.97, "longitude": 77.59}
--- step 2 ---
Thought: Now I can fetch the weather for those coordinates.
Action: get_current_weather
Action Input: {latitude: 12.97, longitude: 77.59}
Observation: Error running get_current_weather: JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
--- step 3 ---
Thought: My input wasn't valid JSON. I'll quote the keys and try again.
Action: get_current_weather
Action Input: {"latitude": 12.97, "longitude": 77.59}
Observation: {"temperature_c": 24.1, "rain_chance_by_hour": {"14:00": 20, "16:00": 65, "18:00": 80, "20:00": 55}}
--- step 4 ---
Thought: The rain chance rises to 65% at 4 pm and 80% at 6 pm. That's likely rain this evening.
Final Answer: Yes, rain is likely in Bengaluru this evening: about 65% around 4 pm and 80% around 6 pm. Take an umbrella if you'll be out after 3 pm.

FINAL ANSWER: Yes, rain is likely in Bengaluru this evening: about 65% around 4 pm and 80% around 6 pm. Take an umbrella if you'll be out after 3 pm.

Step 2 is the important one. The model sent invalid JSON, the tool raised, and the error came back as an observation. The model read it, fixed its input, and carried on. Agents that crash on the first malformed tool call are fragile; agents that turn errors into observations recover from most of them.

2.5 What each safeguard does

Safeguard Without it
stop=["Observation:"] The model invents its own observation and "answers" from made-up data
Errors become observations One bad tool input crashes the whole run
Unknown-tool check The model calls a tool that doesn't exist, and your code raises KeyError
Format reminder when parsing fails The agent stalls when the model drifts from the format
max_steps A confused agent loops forever, burning tokens
temperature=0 Format and tool choice vary more from run to run
Tools validate their input Bad arguments reach real systems (see Tools and MCP)

2.6 The live version

Swap the stand-ins for a real model and the free Open-Meteo APIs (no key needed):

# no-run — pip install openai requests ; needs OPENAI_API_KEY
import requests
from openai import OpenAI

client = OpenAI()

def call_llm(messages):
    response = client.chat.completions.create(model="gpt-4o-mini", messages=messages, temperature=0,
                                              stop=["Observation:"])     # stop before a made-up observation
    return response.choices[0].message.content.strip()

def geocode(city):
    r = requests.get("https://geocoding-api.open-meteo.com/v1/search", params={"name": city, "count": 1}, timeout=10)
    top = r.json()["results"][0]
    return {"name": top["name"], "latitude": top["latitude"], "longitude": top["longitude"]}

def get_weather(latitude, longitude):
    r = requests.get("https://api.open-meteo.com/v1/forecast", timeout=10, params={
        "latitude": latitude, "longitude": longitude, "current": "temperature_2m",
        "hourly": "precipitation_probability", "forecast_days": 1, "timezone": "auto"})
    data = r.json()
    hours = zip(data["hourly"]["time"], data["hourly"]["precipitation_probability"])
    return {"temperature_c": data["current"]["temperature_2m"],
            "rain_chance_by_hour": {t[-5:]: p for t, p in hours if p is not None and p >= 30}}

run_agent, the parsers and the tool registry stay exactly the same.

2.7 Text ReAct vs native tool calling

Modern APIs have native tool calling: you pass JSON schemas, and the model returns structured tool calls instead of text you parse (the tool loop).

Text ReAct (this page) Native tool calling
Tool call format Free text, parsed with regex Structured JSON, validated by the API
Parsing failures Possible; you handle them Rare
Visible reasoning "Thought:" lines in the transcript Optional; reasoning models think internally
Parallel tool calls One per turn Several in one turn
Works with Any text model, including small local ones Models and APIs that support tools
Use today Learning, models without tool support Almost all production agents

The loop is identical either way: the model proposes, your code acts, the result goes back. Frameworks (LangChain's create_agent, LangGraph's tool nodes) wrap this same loop.

Interview questions

Explain the ReAct pattern.

The model interleaves reasoning (Thought) with actions (tool calls), and the results (Observations) are fed back into the context. It repeats until it can give a final answer. Reasoning improves action choice, and observations ground the reasoning in real data instead of guesses.

Why use a stop sequence like 'Observation:' in a text-based ReAct agent?

Without it, the model continues past its Action and writes a plausible but fake Observation, then answers from invented data. Stopping at "Observation:" hands control back to your code, which runs the tool and inserts the real result.

How should an agent handle a tool error?

Catch it and return it to the model as an observation, with a clear message ("invalid JSON: quote the keys", "city not found"), so the model can correct itself. Also cap retries and total steps, validate arguments before running tools, and never let raw exceptions crash the loop or leak secrets.

ReAct with text parsing vs native tool calling: which would you use?

Native tool calling in production: structured, validated calls, parallel calls and fewer parsing errors. Text ReAct is useful for understanding the loop, and for models without tool support. The agent loop and safeguards are the same in both.

Practice

  • Add a third scripted reply that calls a tool named get_forecast (which doesn't exist) and watch the error path.
  • Make SCRIPT repeat the same action forever and check that max_steps stops it.
  • Run the live version (2.6) with your own API key and a different city.

Next: 3. Planning Back to: AI Agents overview · Notes overview