ReAct Pattern: Interleaving Reasoning and Action for LLM Agents › Code Implementation

Michael BrenndoerferFebruary 3, 202610 min read
Language AI HandbookAll chapters
Part 48 of 67Tool Use and Agents
Chapter 365ReAct Pattern: Interleaving Reasoning and Action for LLM Agents › Code Implementation
Reading section 6 of 14 · Code Implementation · 10 minSection 6 of 14 · 10 min

Book contents

Section 6 of 14 · 10 min read

ReAct Pattern: Interleaving Reasoning and Action for LLM Agents › Code ImplementationOne section · Sections in this chapter6 / 14 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement a minimal but complete ReAct system. We'll create a simple question-answering agent with access to a search tool and a calculator. This shows the core loop mechanics. The goal here is not to build a production agent, but to make the mechanics of the loop concrete and observable.

The implementation has five parts: the tools themselves, the agent class that manages the loop, a simulated LLM for demonstration, a function to run the agent, and the visualization of its trajectory.

Setting Up the Tools

First, we define the tools available to the agent. In production, these would be real API calls; here they use a hardcoded knowledge base for reproducibility:

In[4]:
Code
import ast
import operator
from typing import Callable, Dict


def search(query: str) -> str:
    """Simulated search tool with hardcoded knowledge for demo purposes."""
    knowledge_base = {
        "capital of france": "Paris",
        "population of paris": "2.1 million (2020 estimate)",
        "area of paris": "105 square kilometers",
        "eiffel tower height": "330 meters",
        "eiffel tower year built": "1889",
        "author old man sea": "Ernest Hemingway wrote 'The Old Man and the Sea' in 1952",
        "ernest hemingway birthplace": "Oak Park, Illinois",
        "population oak park illinois": "54,583",
        "author the old man and the sea": "Ernest Hemingway wrote The Old Man and the Sea in 1952",
    }
    query_lower = query.lower()
    for key, value in knowledge_base.items():
        if all(word in query_lower for word in key.split()):
            return value
    return f"No results found for: {query}"


# Safe arithmetic evaluator using ast module
_SAFE_OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.Pow: operator.pow,
    ast.USub: operator.neg,
}


def _safe_eval(node):
    """Recursively evaluate a parsed arithmetic AST node."""
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    elif isinstance(node, ast.BinOp) and type(node.op) in _SAFE_OPS:
        left = _safe_eval(node.left)
        right = _safe_eval(node.right)
        return _SAFE_OPS[type(node.op)](left, right)
    elif isinstance(node, ast.UnaryOp) and type(node.op) in _SAFE_OPS:
        return _SAFE_OPS[type(node.op)](_safe_eval(node.operand))
    else:
        raise ValueError(f"Unsupported expression type: {type(node)}")


def calculate(expression: str) -> str:
    """Safe arithmetic calculator that parses and evaluates mathematical expressions."""
    try:
        tree = ast.parse(expression.strip(), mode="eval")
        result = _safe_eval(tree.body)
        return str(result)
    except (ValueError, ZeroDivisionError, SyntaxError) as e:
        return f"Error: {str(e)}"


def finish(answer: str) -> str:
    """Terminal action that returns the final answer."""
    return f"FINAL_ANSWER: {answer}"


# Tool registry
TOOLS: Dict[str, Callable] = {
    "search": search,
    "calculate": calculate,
    "finish": finish,
}

The tool registry is a dictionary mapping tool names to callable functions. This design makes it easy to add or remove tools without changing the agent's core logic. In production, you might also store metadata about each tool (description, parameter schema, usage examples) alongside the callable, which is essentially what the OpenAI function-calling API does.

The calculator uses Python's ast module to parse the expression into a syntax tree and then evaluate only the numeric operations explicitly listed in _SAFE_OPS. This is safer than passing arbitrary strings to eval because it only accepts a restricted grammar of arithmetic operations.

The ReAct Agent Class

Now we implement the agent class that manages the thought-action-observation loop:

In[5]:
Code
import re
from typing import Any, Callable, Dict, List, Tuple


class ReActAgent:
    def __init__(self, tools: Dict[str, Callable], max_iterations: int = 10):
        self.tools = tools
        self.max_iterations = max_iterations
        self.trajectory: List[Dict[str, str]] = []

    def parse_action(self, text: str) -> Tuple[str, str]:
        """Parse 'Action: tool[params]' format from model output."""
        action_match = re.search(r"Action:\s*(\w+)\[(.*?)\]", text, re.DOTALL)
        if not action_match:
            return None, None
        tool_name = action_match.group(1)
        params = action_match.group(2)
        return tool_name, params

    def format_prompt(self, question: str) -> str:
        """Format the full prompt with examples and current trajectory."""
        few_shot_examples = """You are a helpful assistant that answers questions by thinking step-by-step and using tools.
Available tools: search[query], calculate[expression], finish[answer]

Example 1:
Question: What is 15 multiplied by 7?
Thought: I need to calculate 15 * 7.
Action: calculate[15 * 7]
Observation: 105
Thought: I have the answer.
Action: finish[105]

Example 2:
Question: What is the capital of France?
Thought: I need to search for the capital of France.
Action: search[capital of France]
Observation: Paris
Thought: I have found the capital.
Action: finish[Paris]

Now answer the following:"""

        # Build trajectory context
        context = ""
        for step in self.trajectory:
            context += f"\nThought: {step['thought']}"
            context += f"\nAction: {step['action']}"
            context += f"\nObservation: {step['observation']}"

        prompt = f"{few_shot_examples}\nQuestion: {question}{context}\nThought:"
        return prompt

    def step(self, question: str, llm_response: str) -> Tuple[str, bool]:
        """Execute one step of the ReAct loop.

        Returns: (observation, is_finished)
        """
        # Parse thought (everything before Action)
        thought_match = re.match(r"(.*?)Action:", llm_response, re.DOTALL)
        if thought_match:
            thought = thought_match.group(1).strip()
            if thought.startswith("Thought:"):
                thought = thought[8:].strip()
        else:
            thought = llm_response.strip()

        # Parse action
        tool_name, params = self.parse_action(llm_response)

        if not tool_name:
            return (
                "Error: Could not parse action. Please use format 'Action: tool[params]'",
                False,
            )

        if tool_name == "finish":
            return self.tools[tool_name](params), True

        if tool_name not in self.tools:
            return f"Error: Unknown tool '{tool_name}'", False

        # Execute tool
        observation = self.tools[tool_name](params)

        # Record in trajectory
        self.trajectory.append(
            {
                "thought": thought,
                "action": f"{tool_name}[{params}]",
                "observation": observation,
            }
        )

        return observation, False

    def run(
        self, question: str, llm_simulator: Callable[[str], str]
    ) -> Dict[str, Any]:
        """Run the full ReAct loop.

        Args:
            question: The question to answer
            llm_simulator: Function that takes prompt and returns LLM response
        """
        self.trajectory = []

        for i in range(self.max_iterations):
            prompt = self.format_prompt(question)
            response = llm_simulator(prompt)

            # Add the generated thought/action to prompt for parsing
            full_response = "Thought: " + response

            observation, is_finished = self.step(question, full_response)

            if is_finished:
                return {
                    "success": True,
                    "answer": observation.replace("FINAL_ANSWER: ", ""),
                    "trajectory": self.trajectory,
                    "steps": i + 1,
                }

        return {
            "success": False,
            "answer": None,
            "trajectory": self.trajectory,
            "steps": self.max_iterations,
            "reason": "Max iterations reached",
        }

The parse_action method uses a regular expression to extract the tool name and parameters from the model's output. The regex (\w+)\[(.*?)\] matches patterns like search[query text] or calculate[15 * 7]. This is deliberately simple. Production systems often need more reliable parsers that handle multi-line parameters, special characters, or JSON-formatted arguments.

The format_prompt method constructs the full context window contents at each step. Notice that it always starts from the full few-shot examples and then appends the complete trajectory. This means the model sees the same examples every time, which is important for maintaining format consistency. The trajectory context appended at the end grows with each iteration, which is the source of the linear token growth discussed in the limitations section.

The run method implements the outer loop with a maximum iteration guard. The guard is needed: without it, a confused or stuck agent would consume API credits indefinitely. Hitting the maximum iteration limit without a finish action is a detectable failure mode that the calling system can handle, for example by returning a "could not answer" response or escalating to a human.

Simulating LLM Responses

For demonstration purposes, we simulate the LLM responses to show exactly what a correct trajectory looks like:

In[6]:
Code
def simulate_llm(
    prompt: str, step_responses: List[str], step_counter: List[int]
) -> str:
    """Simulate LLM responses for demo purposes."""
    current_step = step_counter[0]
    step_counter[0] += 1
    if current_step < len(step_responses):
        return step_responses[current_step]
    return "finish[Unable to complete]"


# Define the response sequence for our multi-hop question
responses = [
    # Step 1: Search for author
    "I need to find who wrote 'The Old Man and the Sea'.\nAction: search[author The Old Man and the Sea]",
    # Step 2: Search for birthplace (after seeing observation)
    "The author is Ernest Hemingway. Now I need to find where he was born.\nAction: search[Ernest Hemingway birthplace]",
    # Step 3: Search for population
    "He was born in Oak Park, Illinois. Now I need the population of Oak Park.\nAction: search[population Oak Park Illinois]",
    # Step 4: Finish
    "I have found that Oak Park, Illinois has a population of 54,583. I can now provide the final answer.\nAction: finish[54,583]",
]

Running the Agent

Now let's run the agent and observe its trajectory:

In[7]:
Code
# Initialize agent and run
agent = ReActAgent(TOOLS, max_iterations=10)
step_counter = [0]  # Use list to allow mutation in closure

result = agent.run(
    "What is the population of the city where the author of 'The Old Man and the Sea' was born?",
    lambda p: simulate_llm(p, responses, step_counter),
)
Out[8]:
Console
Success: True
Answer: 54,583
Steps taken: 4

Full Trajectory:

Step 1:
  Thought: I need to find who wrote 'The Old Man and the Sea'.
  Action: search[author The Old Man and the Sea]
  Observation: Ernest Hemingway wrote 'The Old Man and the Sea' in 1952

Step 2:
  Thought: The author is Ernest Hemingway. Now I need to find where he was born.
  Action: search[Ernest Hemingway birthplace]
  Observation: Oak Park, Illinois

Step 3:
  Thought: He was born in Oak Park, Illinois. Now I need the population of Oak Park.
  Action: search[population Oak Park Illinois]
  Observation: 54,583

The agent successfully completes the multi-hop reasoning task in four steps. This shows how the ReAct pattern chains together disparate pieces of information into a coherent solution. Each thought explicitly references the previous observation. This creates an auditable reasoning chain that traces the path from question to answer.

Visualizing the Trajectory

The trajectory diagram below makes the iterative structure concrete. Each column represents a phase (Thought, Action, Observation), and each row represents one iteration:

Out[9]:
Visualization
Flowchart showing three rows of thought, action, observation boxes connected by arrows, leading to a final answer box.
Three iterations of the ReAct thought-action-observation loop for the multi-hop question about Hemingway's birthplace. Each row shows one iteration, with observations feeding directly into the next thought. The dashed lines on the left indicate that cumulative context from all prior iterations is available at each step.

The diagram makes one important feature visible that is easy to overlook in the code: the cumulative context arrows on the left side. Each new thought has access to the immediate previous observation and to the entire trajectory of prior thoughts and actions plus their observations. This accumulation is what allows the agent to refer back to earlier findings even several steps later.