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
Pattern
Doctrine relationsWhich principles this pattern expresses in code, and which its default form breaks.Library
Browse examplesReopen the wider library to compare adjacent patterns and linked learning paths.Interaction
Run sandbox nowTry the interaction directly in this example’s guided sandbox surface.Source
Open full sourceRead the real implementation, highlighted checkpoints, and runtime requirements.MCP
Call via MCPUse the same resource inside agents, deterministic exports, and MCP setup flows.
Linked principles
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.
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
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:runP8 · 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:runP10 · 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.
react_loop.py
"""
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)
Related principles
- P2visibilityEnsure that background work remains perceptibleWhen the system is operating asynchronously or outside the user’s immediate focus, it should provide persistent and proportionate signals that work is continuing.Open principle →
- P7trustEstablish trust through inspectabilityUsers should be able to examine how a result was produced when confidence, accountability, or decision quality is important.Open principle →
- P8trustMake hand-offs, approvals, and blockers explicitWhen the system cannot proceed, the reason should be immediately visible, along with any action required from the user or another dependency.Open principle →
- P10delegationOptimise for steering, not only initiatingThe system should support users not only in starting tasks, but also in guiding, refining, reprioritising, and correcting work while it is underway.Open principle →