Skip to content

ReAct: Let the Travel Assistant Check, Observe, and Adjust

Do not start by memorizing the name ReAct.

Start with the plain chatbot version:

answer = model.complete([
    {"role": "user", "content": "Plan a relaxed one-day Hangzhou trip for tomorrow. I like tea, local food, and easy walking. Tell me what to pack."}
])

It may write a confident itinerary. The problem is hidden inside that confidence: it did not check the weather, it does not know whether rain changes the afternoon, and it did not estimate the route. It guessed a complete answer in one shot.

Making the prompt longer is not always the fix. Sometimes the model needs to do one thing, see what came back, and then choose the next thing.

That is the job of ReAct here.

One Sentence

ReAct turns a one-shot answer into an action loop: the model chooses the next action, while Python executes it, records the observation, enforces limits, and stops the run.

The original paper frames ReAct as reasoning plus acting. In implementation, we do not need to expose private chain-of-thought. We need a parseable, executable, auditable action protocol.

What Breaks Without It

For the Hangzhou trip, a one-shot chatbot often fails in three quiet ways:

Problem What it looks like Risk
Inventing live facts "Tomorrow is good for a lake walk" It may rain in the afternoon
Planning all at once "Visit West Lake, the tea museum, and Hefang Street" Order ignores weather and transit
Hiding the basis "Pack comfortable shoes and a jacket" You cannot tell whether this came from weather, walking distance, or habit

ReAct does not magically make the model smarter. It gives the model a chance to revise the next step after each observation.

What Changes

A normal chatbot returns the final answer:

{"answer": "Go to West Lake in the morning, Longjing Village in the afternoon, and Hefang Street at night."}

With ReAct, the model first returns an action:

{"type": "tool", "tool": "get_weather", "args": {"city": "Hangzhou", "date": "tomorrow"}}

Python receives that action and calls the tool. The tool result is appended to the message history:

{"forecast": "light rain after 15:00", "packing_hint": "umbrella and light jacket"}

Now the model can see the rain before choosing the next action. When it has enough information, it returns:

{"type": "final", "answer": "Visit West Lake in the morning, move indoors to the tea museum after the rain starts, then walk Hefang Street for snacks."}

Keep the control boundary clear:

Who Owns
Model Chooses the next action: tool, final answer, or user question
Python Validates actions, calls tools, records observations, writes traces, enforces step limits, handles failures

Walk Through One Trace

This trace matters more than the definition. ReAct becomes useful when an observation changes the next step.

Round Model requests Python executes Observation Why the next step changes
1 get_weather Checks tomorrow's Hangzhou weather Light rain after 15:00, 18–23°C Afternoon should not be all outdoors
2 search_places Searches by interests and weather West Lake, Tea Museum, Hefang Street Tea Museum fits the rainy part of the day
3 estimate_route Estimates order and travel time About 55 minutes total transit The route stays relaxed
4 final Stops the loop Plan and packing advice Enough evidence has been gathered

Flow

flowchart TD
  U["User asks for a Hangzhou day trip"] --> S["Message history: task + observations"]
  S --> M["Model chooses one action"]
  M -->|get_weather| W["Python checks weather"]
  W --> OW["Append observation: afternoon rain"]
  OW --> S
  M -->|search_places| P["Python finds places"]
  P --> OP["Append observation: West Lake / Tea Museum / Hefang Street"]
  OP --> S
  M -->|estimate_route| R["Python estimates route"]
  R --> OR["Append observation: route and time"]
  OR --> S
  M -->|final| F["Return travel plan"]
  S --> L["RunLimits: max 6 steps"]
  L -->|limit hit| X["Hard stop"]

Start With The Tools

A tool is just a Python function. The clearer the contract, the easier it is for the model to choose it.

def get_weather(args: dict[str, Any]) -> str:
    return as_json(
        {
            "city": args["city"],
            "date": args["date"],
            "forecast": "light rain after 15:00",
            "temperature_c": "18-23",
            "packing_hint": "umbrella and light jacket",
        }
    )

The model gets fields it can use: rain timing, temperature, and packing hints. Not vague prose.

Then register the tools:

tools = ToolRegistry(
    [
        Tool(
            name="get_weather",
            description="Get a simple weather forecast for a city and date",
            handler=get_weather,
        ),
        Tool(
            name="search_places",
            description="Find travel places based on city, interests, and constraints",
            handler=search_places,
        ),
        Tool(
            name="estimate_route",
            description="Estimate travel time for a short list of places",
            handler=estimate_route,
        ),
    ]
)

The description is part of the tool contract. If it reads like marketing copy, the model guesses. If it reads like a function contract, the model has a better chance.

Then Look At The Loop

The important part of run_react(...) is step(...):

action = structured_complete(
    model,
    messages,
    parser=parse_action,
    schema_hint=ACTION_SCHEMA_HINT,
    tracer=tracer,
)

The model must return a valid action. In this lab the action is one of tool, final, or ask.

If the action is final, the loop ends:

if isinstance(action, FinalAction):
    return action.answer

If the model needs missing user information, do not let it invent:

if isinstance(action, AskAction):
    raise NeedUserInput(action.question)

If the action calls a tool, Python executes it and appends both the action and the observation:

tool_out = tools.call(action.tool, action.args, tracer=tracer)
messages.append(Message(role="assistant", content=action_to_json(action)))
messages.append(Message(role="tool", name=action.tool, content=tool_out))
return None

Those two append calls are what make the next round different from the previous one.

Finally, the outer runner adds a hard limit:

return run_loop(step, limits=limits, tracer=tracer)

An agent loop without max_steps is a script without brakes. It can keep checking, searching, and never answer.

Full Example

The snippets above come from this runnable example:

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from agent_patterns_lab.patterns.react import run_react
from agent_patterns_lab.runtime import MockLLM, RunLimits, Tool, ToolRegistry, Tracer


def as_json(value: dict[str, Any]) -> str:
    return json.dumps(value, ensure_ascii=False)


def main() -> None:
    tracer = Tracer()

    def get_weather(args: dict[str, Any]) -> str:
        city = args["city"]
        date = args["date"]
        return as_json(
            {
                "city": city,
                "date": date,
                "forecast": "light rain after 15:00",
                "temperature_c": "18-23",
                "packing_hint": "umbrella and light jacket",
            }
        )

    def search_places(args: dict[str, Any]) -> str:
        return as_json(
            {
                "city": args["city"],
                "matches": [
                    "West Lake: best before the afternoon rain",
                    "China National Tea Museum: indoor-friendly and good for tea lovers",
                    "Hefang Street: local snacks and easy evening walk",
                ],
            }
        )

    def estimate_route(args: dict[str, Any]) -> str:
        places = args["places"]
        return as_json(
            {
                "route": places,
                "total_transit_minutes": 55,
                "note": "Keep West Lake first, move indoor after rain starts.",
            }
        )

    tools = ToolRegistry(
        [
            Tool(
                name="get_weather",
                description="Get a simple weather forecast for a city and date",
                handler=get_weather,
            ),
            Tool(
                name="search_places",
                description="Find travel places based on city, interests, and constraints",
                handler=search_places,
            ),
            Tool(
                name="estimate_route",
                description="Estimate travel time for a short list of places",
                handler=estimate_route,
            ),
        ]
    )

    model = MockLLM(
        [
            as_json(
                {
                    "type": "tool",
                    "tool": "get_weather",
                    "args": {"city": "Hangzhou", "date": "tomorrow"},
                }
            ),
            as_json(
                {
                    "type": "tool",
                    "tool": "search_places",
                    "args": {
                        "city": "Hangzhou",
                        "interests": ["tea", "local food", "easy walking"],
                        "constraint": "light rain after 15:00",
                    },
                }
            ),
            as_json(
                {
                    "type": "tool",
                    "tool": "estimate_route",
                    "args": {
                        "places": [
                            "West Lake",
                            "China National Tea Museum",
                            "Hefang Street",
                        ]
                    },
                }
            ),
            as_json(
                {
                    "type": "final",
                    "answer": (
                        "Plan: West Lake in the morning, China National Tea Museum after "
                        "the rain starts, then Hefang Street for snacks. Pack an umbrella, "
                        "a light jacket, and comfortable shoes."
                    ),
                }
            ),
        ]
    )

    out = run_react(
        model,
        task=(
            "Plan a relaxed one-day Hangzhou trip for tomorrow. "
            "I like tea, local food, and easy walking. Tell me what to pack."
        ),
        tools=tools,
        limits=RunLimits(max_steps=6),
        tracer=tracer,
    )

    print(out)
    trace_path = tracer.export_jsonl(Path(".traces") / "21_react_loop.jsonl")
    print(f"[trace] {trace_path}")


if __name__ == "__main__":
    main()

Run:

uv run python examples/21_react_loop.py

If local imports fail, use the project command:

UV_CACHE_DIR=.uv_cache PYTHONPATH=src uv run --no-sync python examples/21_react_loop.py

Expected output:

Plan: West Lake in the morning, China National Tea Museum after the rain starts, then Hefang Street for snacks. Pack an umbrella, a light jacket, and comfortable shoes.
[trace] .traces/21_react_loop.jsonl

The first line is the answer. The second line is the trace path. When debugging ReAct, the trace is often more useful than the final answer.

Text ReAct vs JSON ReAct

Many ReAct tutorials use this teaching format:

Thought: I need to check the weather.
Action: get_weather[Hangzhou, tomorrow]
Observation: It will rain after 15:00.
Thought: Then the afternoon should move indoors.
Action: search_places[tea, indoor-friendly]
Final: ...

That is useful for learning the idea. In code, JSON actions are easier to live with:

{"type": "tool", "tool": "get_weather", "args": {"city": "Hangzhou", "date": "tomorrow"}}

JSON can be parsed, validated, traced, and tested. Free-form actions break as soon as the model adds one extra sentence.

Also note what this is not: the page records actions and observations, not the model's private reasoning. Production systems usually log auditable actions, tool results, and summaries.

Nearby Patterns

Pattern Who decides next Use when
Chatbot No next step; one answer Rewrite, summarize, simple Q&A
One tool call Code or model calls one tool Weather, exchange rate, inventory lookup
Prompt Chaining Python follows fixed steps The order is known: extract → check → rewrite
ReAct Model chooses after each observation You do not know how many calls are needed
Planner-Executor-Replanner Planner makes and revises a plan The task is longer and constraints may change

If a fixed workflow solves the problem, do not use ReAct. Workflows are cheaper, easier to test, and easier to explain. ReAct pays off when the next step really depends on the latest observation.

When To Use It

  • Tool output changes the next step, such as rain changing the route.
  • You do not know how many tool calls are needed.
  • A tool may fail, so the agent needs to retry, switch tools, or ask the user.
  • You need to replay why the agent answered the way it did.

When Not To Use It

  • The task is simple rewriting.
  • All steps are already known; use a workflow.
  • One external lookup is enough.
  • Tools can book, pay, delete, or mutate real data. Add policy, guardrails, and human confirmation before putting them inside a loop.

Costs And Common Failures

Failure Symptom Fix
Infinite loop Keeps checking weather or places Add max_steps, timeout, stagnation checks
Wrong tool Checks weather when it should estimate route Narrow the tool list, rewrite tool descriptions
Fake observation Says "sunny" without a tool result Trust only Python-written tool messages
High cost One task becomes many model calls Cache, cap steps, prefer workflows when possible
Messy trace You cannot tell where it failed Log action, tool args, tool output, stop reason

What To Remember

Use ReAct when the next step must depend on the previous tool result, and keep the loop under Python's control.

If the next problem is noisy or incomplete retrieval, read Agentic RAG. If the next problem is a longer plan that needs revision, read Planner-Executor-Replanner. If tools can affect the real world, read Guardrails and HITL first.

References