AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

ReAct agents

ReAct — short for “reason and act” — is the pattern behind most agents: the model reasons about what to do, takes an action with a tool, observes the result, and repeats until it can answer. This lesson builds a complete ReAct agent in plain Python, runs it on a multi-step question so you can read every step, and then adds the stop conditions that separate a demo from a system you can trust.

Last updated: 01 Nov, 2026

The ReAct pattern

Definition · ReAct

An agent pattern (Yao et al., 2022) that interleaves reasoning (thinking about what is known and what to do next) with acting (calling a tool) and observing (reading the tool's result). Grounding each step in real observations reduces hallucination compared with reasoning alone.

ReAct trace
Question: How many hours do I have to request a refund on an annual plan?

Thought:     I need the refund window. I should search the policy.
Action:      search("annual plan refund window")
Observation: "Annual plans can be refunded within 30 days of purchase."

Thought:     30 days. I need to convert days to hours.
Action:      calculator("30 * 24")
Observation: 720

Thought:     I have the answer.
Answer:      You have 720 hours (30 days) from purchase.

Early ReAct agents parsed “Action:” lines out of plain text. Modern agents use native tool calling for the actions — far more reliable — and the model's reasoning happens in its text output or internal reasoning tokens.

The tools

Python
import json
import re

KNOWLEDGE = {
    "refund": "Annual plans can be refunded within 30 days of purchase. Monthly plans are not refunded.",
    "backup": "Backups run every night at 2 AM and are kept for 90 days.",
}


def search(query: str) -> str:
    hits = [text for key, text in KNOWLEDGE.items() if key in query.lower()]
    return hits[0] if hits else "No results."


def calculator(expression: str) -> str:
    if not re.fullmatch(r"[\d\s+\-*/().]+", expression):
        return "Error: only numbers and + - * / ( ) are allowed."
    return str(eval(expression, {"__builtins__": {}}))    # safe only because of the check above


TOOLS = {"search": search, "calculator": calculator}
print(search("annual plan refund window"))
print(calculator("30 * 24"))
print(calculator("__import__('os').remove('x')"))
Annual plans can be refunded within 30 days of purchase. Monthly plans are not refunded. 720 Error: only numbers and + - * / ( ) are allowed.

A scripted model

To see the loop clearly, this stand-in model makes the decisions a real model would, based only on the messages it is given. Each decision is either a tool call or a final answer.

Python
def fake_model(messages: list[dict]) -> dict:
    observations = [m["content"] for m in messages if m["role"] == "tool"]
    if not observations:
        return {"thought": "I need the refund window; search the policy.",
                "tool": "search", "args": {"query": "annual plan refund window"}}
    days = re.search(r"within (\d+) days", " ".join(observations))
    if days and len(observations) == 1:
        return {"thought": f"The window is {days.group(1)} days; convert to hours.",
                "tool": "calculator", "args": {"expression": f"{days.group(1)} * 24"}}
    return {"thought": "I have everything I need.",
            "answer": f"You have {observations[-1]} hours ({days.group(1)} days) from purchase."}

The agent loop

Python
def run_agent(question: str, model=fake_model, max_steps: int = 5) -> str:
    messages = [{"role": "user", "content": question}]
    for step in range(1, max_steps + 1):
        decision = model(messages)
        print(f"[{step}] thought: {decision['thought']}")
        if "answer" in decision:
            return decision["answer"]
        name, args = decision["tool"], decision["args"]
        if name not in TOOLS:
            observation = f"Error: unknown tool {name}"
        else:
            observation = TOOLS[name](**args)
        print(f"    action: {name}({json.dumps(args)})")
        print(f"    observation: {observation}")
        messages.append({"role": "assistant", "content": json.dumps(decision)})
        messages.append({"role": "tool", "content": observation})
    return "Stopped: step limit reached without an answer."


print("answer:", run_agent("How many hours do I have to request a refund on an annual plan?"))
[1] thought: I need the refund window; search the policy. action: search({"query": "annual plan refund window"}) observation: Annual plans can be refunded within 30 days of purchase. Monthly plans are not refunded. [2] thought: The window is 30 days; convert to hours. action: calculator({"expression": "30 * 24"}) observation: 720 [3] thought: I have everything I need. answer: You have 720 hours (30 days) from purchase.

Read the trace as a debugger would: each step shows what the model decided, what your code executed, and what came back. Two tool calls, one answer, three model calls.

The live version with tool calling

Python
SCHEMAS = [
    {"type": "function", "function": {
        "name": "search", "description": "Search the product policy documents.",
        "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
    {"type": "function", "function": {
        "name": "calculator", "description": "Evaluate an arithmetic expression like '30 * 24'.",
        "parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}}},
]
SYSTEM = ("You answer questions about our product. Use search for facts and calculator for arithmetic. "
          "Never guess numbers. When you have the answer, reply without calling tools.")


def run_live_agent(question: str, max_steps: int = 6) -> str:
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}]
    for _ in range(max_steps):
        response = client.chat.completions.create(model="gpt-4.1-mini", messages=messages, tools=SCHEMAS)
        message = response.choices[0].message
        if not message.tool_calls:
            return message.content                          # final answer
        messages.append(message)
        for call in message.tool_calls:
            args = json.loads(call.function.arguments)
            result = TOOLS[call.function.name](**args)
            messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
    return "Stopped: step limit reached."
You have 720 hours (30 days) from purchase to request a refund on an annual plan.

Stop conditions

An agent without limits will eventually loop: it searches the same thing again, retries a failing tool forever, or keeps “improving” an answer. Every production agent enforces several independent limits.

Stop conditionProtects againstTypical setting
Max stepsEndless loops5–15 tool steps
Repeated action detectionCalling the same tool with the same arguments again and againStop or warn after 2 repeats
Token or cost budgetRunaway spendA fixed limit per task
Wall-clock timeoutHung tools, slow tasksSeconds to minutes per task
Consecutive error limitFlailing on a broken toolStop after 3 errors in a row
Explicit finish tool or final answerNever deciding it is doneRequired in the prompt
Python
def stubborn_model(messages: list[dict]) -> dict:
    """A badly behaved model that keeps searching for the same thing."""
    return {"thought": "Let me search again.", "tool": "search", "args": {"query": "pricing"}}


def run_guarded(question: str, model, max_steps: int = 6, max_repeats: int = 2) -> str:
    messages = [{"role": "user", "content": question}]
    seen: dict[str, int] = {}
    for step in range(1, max_steps + 1):
        decision = model(messages)
        if "answer" in decision:
            return decision["answer"]
        key = f"{decision['tool']}:{json.dumps(decision['args'], sort_keys=True)}"
        seen[key] = seen.get(key, 0) + 1
        if seen[key] > max_repeats:
            return f"Stopped at step {step}: repeated action {key} {seen[key]} times."
        observation = TOOLS[decision["tool"]](**decision["args"])
        messages.append({"role": "tool", "content": observation})
    return "Stopped: step limit reached."


print(run_guarded("How much does it cost?", stubborn_model))
Stopped at step 3: repeated action search:{"query": "pricing"} 3 times.
Note

When an agent stops early, return what it found so far and why it stopped, rather than a bare error. A partial result plus “I could not find the pricing page” is useful; a silent failure is not.

Making agents more reliable

  • Write a clear system prompt: the goal, the tools and when to use each, what to do when information is missing, and how to finish.
  • Return informative observations: short, relevant results and explicit error messages.
  • Keep the tool set small for each agent; split big jobs between specialised agents or workflow steps.
  • Plan first for long tasks: ask the model to write a short plan, then execute it step by step and revise it when observations change things.
  • Log every step: you cannot fix what you cannot see. Traces are the main input to evaluation.
Try it
  • Add a max_errors limit to run_guarded that stops after three consecutive error observations.
  • Ask run_agent a question about backups and make fake_model handle it.
  • Record each step as a dict (step, tool, args, observation) and save the run to trace.json.

Key takeaways

Key takeaways
  • ReAct interleaves reasoning, tool actions, and observations until the model can answer.
  • The loop itself is a few lines of code; reliability comes from tools, prompts, and limits.
  • Use native tool calling for actions instead of parsing text.
  • Always enforce max steps, repeat detection, budgets, and timeouts.
  • When stopping early, report partial findings and the reason.