Skip to main contentSkip to footer
PatternscriptbeginnerRunnableguided-flow

ReAct: reason, act, observe, repeat

The model alternates between explicit reasoning and tool calls, feeding each observation back until it can answer. The reasoning is a first-class inspectable field rather than something that happened inside the model, and that is what separates ReAct from plain tool-calling.

Key Facts

Level
beginner
Runtime
Python • OpenAI API
Pattern
Inspectable flow with visible system boundaries
Interaction
Live sandbox • Script
Updated
27 July 2026

Navigate this example

High-level flow

How this example moves from input to execution and reviewable output

ReAct: reason, act, observe, repeat -> Load environment keys -> Initialize OpenAI client -> Constrain output schema -> Send message payload -> Retry after failure

Start

ReAct: reason, act, observe, repeat

Checkpoint

Load environment keys

Outcome

Initialize OpenAI client

Why this page exists

This example is shown as both real source code and a product-facing interaction pattern so learners can connect implementation, UX, and doctrine without leaving the library.

Visual flowReal sourceSandbox or walkthroughMCP access

Doctrine relations

These are reasoning shapes: how an agent decides, when it acts, what it remembers. For how to compose the interface around it, see the AI Pattern Blueprint. For triggers, scheduling, context loading and recovery, see Agent runtime architecture.

Structural

This pattern expresses these principles directly in code.

  • P7 · Establish trust through inspectability

    The defining property of ReAct over plain tool-calling: `thought` is a required field on every step, so the reasoning that led to each action is in the transcript rather than inside the model. A reader can re-derive why any given tool was called.

    react_loop.py:Step

Default gap

The textbook form of this pattern violates these by default. Each one carries the fix.

  • P2 · Ensure that background work remains perceptible

    Default ReAct returns only the final answer. The loop iterates server-side and the caller sees nothing until it finishes, which reads as a hang. Fix, and this implementation does it deliberately: emit the step signal BEFORE the model call, not after, so a step that is still running is visible as running.

    react_loop.py:run

  • P8 · Make hand-offs, approvals, and blockers explicit

    Every action the model selects executes. There is no approval boundary anywhere in the loop, which is safe here only because both tools are read-only. Fix: compose with the dry-run pattern so any irreversible action is proposed, simulated, and gated before it runs.

    react_loop.py:run

  • P10 · Optimise for steering, not only initiating

    Once started the loop runs to an answer or to MAX_STEPS. The operator can abort it but cannot redirect it, because there is no point at which new instruction is accepted. Fix: check a steer channel between iterations and fold any pending instruction into the transcript. This implementation does NOT do that, and is a faithful example of the gap.

    react_loop.py:run

Doctrine relations are first-party analysis, not the output of a validation run. Run the validator on your own code to get a scored verdict.

How should this example be used in the platform?

Use the sandbox to understand the experience pattern first, then inspect the source to see how the product boundary, model boundary, and doctrine boundary are actually implemented.

UX pattern: Inspectable flow with visible system boundaries
Ensure that background work remains perceptible
Establish trust through inspectability
Make hand-offs, approvals, and blockers explicit

Source references

Library entry
pattern-react
Source path
content/patterns/tools-actions/react/react_loop.py
Libraries
openai, pydantic, python-dotenv
Runtime requirements
OPENAI_API_KEY
Related principles
Ensure that background work remains perceptible, Establish trust through inspectability, Make hand-offs, approvals, and blockers explicit, Optimise for steering, not only initiating

react_loop.py

python
"""
ReAct: reason, act, observe, repeat

The loop that sits underneath most working agents. The model does not answer
directly. It alternates between reasoning about what it needs and acting to
get it, feeding each observation back until it can answer.

    Thought -> Action -> Observation -> Thought -> ... -> Answer

What separates ReAct from plain tool-calling is that the reasoning is an
explicit, inspectable field rather than something that happened inside the
model. That is the property worth keeping.

Two things this shape does NOT give you for free, both deliberate here:

  - Nothing stops an action. Every action the model picks runs. Compose with
    the dry-run pattern if any action is irreversible.
  - Nothing steers it. Once started, the loop runs to MAX_STEPS or to an
    answer. You can abort it; you cannot redirect it.

Run: python react_loop.py
"""

import ast
import operator
from typing import Literal

from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, Field

load_dotenv()

client = OpenAI()

MODEL = "gpt-5.4-mini"
MAX_STEPS = 6

# --------------------------------------------------------------
# Tools
# --------------------------------------------------------------

INVENTORY = {
    "widget": {"stock": 12, "unit_price": 9.99},
    "gadget": {"stock": 0, "unit_price": 24.50},
    "sprocket": {"stock": 340, "unit_price": 1.25},
}


def lookup_item(name: str) -> str:
    item = INVENTORY.get(name.strip().lower())
    if item is None:
        return f"unknown item '{name}'. Known items: {', '.join(INVENTORY)}"
    return f"{name}: stock={item['stock']}, unit_price={item['unit_price']}"


#: The only operations the calculator will perform. Anything outside this map
#: is refused by structure rather than by pattern-matching the input.
_BINARY_OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
}
_UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg}


def _evaluate_node(node: ast.expr) -> float:
    """Walk a parsed expression, admitting only numbers and the four operators.

    Deliberately not `eval`. A tool an agent can call with model-authored input
    is the last place to hand over an interpreter, and a character allowlist in
    front of `eval` is a filter someone will eventually widen by one character.
    Refusing by structure cannot be widened by accident.
    """
    if isinstance(node, ast.Constant):
        if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
            raise ValueError(f"not a number: {node.value!r}")
        return float(node.value)
    if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPS:
        return _BINARY_OPS[type(node.op)](
            _evaluate_node(node.left), _evaluate_node(node.right)
        )
    if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
        return _UNARY_OPS[type(node.op)](_evaluate_node(node.operand))
    raise ValueError(f"unsupported expression element: {type(node).__name__}")


def calculate(expression: str) -> str:
    """Arithmetic only, over numbers and + - * / with parentheses."""
    if not expression.strip():
        return "refused: empty expression"
    try:
        parsed = ast.parse(expression, mode="eval")
        return str(round(_evaluate_node(parsed.body), 4))
    except ZeroDivisionError:
        return f"could not evaluate '{expression}': division by zero"
    except (SyntaxError, ValueError, TypeError) as exc:
        return f"refused: '{expression}' is not plain arithmetic ({exc})"


TOOLS = {"lookup_item": lookup_item, "calculate": calculate}

TOOL_DOCS = """
lookup_item(name)      -> stock and unit price for one inventory item
calculate(expression)  -> evaluate a plain arithmetic expression
""".strip()


# --------------------------------------------------------------
# One turn of the loop
# --------------------------------------------------------------


class Step(BaseModel):
    thought: str = Field(
        description="What you know so far and what you still need. Always required."
    )
    next: Literal["act", "answer"]
    action: Literal["lookup_item", "calculate"] | None = None
    action_input: str | None = None
    answer: str | None = Field(
        default=None, description="Set only when next == 'answer'."
    )


def think(question: str, transcript: list[str]) -> Step:
    history = "\n".join(transcript) if transcript else "(nothing yet)"
    completion = client.beta.chat.completions.parse(
        model=MODEL,
        messages=[
            {
                "role": "system",
                "content": (
                    "You answer questions by reasoning and using tools, one step "
                    "at a time.\n\nTools:\n" + TOOL_DOCS + "\n\n"
                    "State your reasoning in `thought` every step. Set next='act' "
                    "with an action to gather information, or next='answer' when "
                    "the transcript already contains everything you need. Never "
                    "guess a number you could look up."
                ),
            },
            {
                "role": "user",
                "content": f"Question: {question}\n\nTranscript so far:\n{history}",
            },
        ],
        response_format=Step,
    )
    return completion.choices[0].message.parsed


def run(question: str) -> str:
    transcript: list[str] = []

    for step_number in range(1, MAX_STEPS + 1):
        # Emitted BEFORE the model call, not after it. A step that is still
        # running is visible as "running", which is the whole point.
        print(f"  [{step_number}/{MAX_STEPS}] thinking...")

        step = think(question, transcript)
        print(f"      thought: {step.thought}")
        transcript.append(f"Thought: {step.thought}")

        if step.next == "answer":
            answer = step.answer or "(model chose to answer but gave none)"
            print(f"      answer : {answer}")
            return answer

        if not step.action or step.action not in TOOLS:
            observation = f"no such tool: {step.action}"
        else:
            print(f"      action : {step.action}({step.action_input or ''})")
            observation = TOOLS[step.action](step.action_input or "")

        print(f"      observed: {observation}")
        transcript.append(
            f"Action: {step.action}({step.action_input}) -> {observation}"
        )

    # Budget exhausted is a real outcome, not a crash. Say so plainly and
    # hand back what was learned rather than raising.
    exhausted = (
        f"Stopped after {MAX_STEPS} steps without reaching an answer. "
        f"What was established:\n" + "\n".join(transcript)
    )
    print("      BUDGET EXHAUSTED")
    return exhausted


if __name__ == "__main__":
    for question in [
        "What does it cost to buy every widget we have in stock?",
        "Can I order 5 gadgets right now?",
    ]:
        print(f"\n> {question}")
        run(question)

What should the learner inspect in the code?

Look for the exact place where system scope is bounded: schema definitions, prompt framing, runtime configuration, and the call site that turns user intent into a concrete model or workflow action.

Look for output contracts and validation
Look for the exact execution call
Look for what the product could expose to the user

How does the sandbox relate to the source?

The sandbox should make the UX legible: what the user sees, what the system is deciding, and how the result becomes reviewable. The source then shows how that behavior is actually implemented.

Read the implementation summary.
Step through the user and system states.
Inspect the source code with the highlighted doctrine decisions in mind.
SandboxInspectable flow with visible system boundaries

Interaction walkthrough

Use the sandbox to step through the user-visible experience, the system work behind it, and the doctrine choice the example is making.

UX explanation

The sandbox explains what the user should see, what the system is doing, and where control or inspectability must remain explicit.

AI design explanation

The page turns raw source into a product-facing pattern: what the model is allowed to decide, what the product should expose, and where deterministic code or review should take over.

Interaction walkthrough

  1. 1Read the implementation summary.
  2. 2Step through the user and system states.
  3. 3Inspect the source code with the highlighted doctrine decisions in mind.

Visible to the user

The model alternates between explicit reasoning and tool calls, feeding each observation back until it can answer. The reasoning is a first-class inspectable field rather than something that happened inside the model, and that is what separates ReAct from plain tool-calling.

System work

The product prepares a bounded model or workflow task.

Why it matters

The interface should make the delegated task legible before automation happens.

Used in courses and paths

This example currently stands on its own in the library, but it still connects to the principle system and the broader example family.

Related principles

Runtime architecture

Use this example in your agents

This example is also available through the blueprint’s agent-ready layer. Use the For agents page for the public MCP, deterministic exports, and Claude/Cursor setup.

Define triggers, context, and boundaries before increasing autonomy
Make control, observability, and recovery explicit in the runtime
Choose the right operational patterns before delegating to workflows