πŸŽ‰ 75% of content is free forever β€” Unlock Premium from $10/mo β†’
CW
πŸ’Ό Servicesℹ️ Aboutβœ‰οΈ ContactView Pricing Plansfrom $10

Building Your First ReAct Agent from Scratch

AI AgentsBuilding Your First Agent🟒 Free Lesson

Advertisement

Building Your First ReAct Agent from Scratch

ReAct Agent Architecture

ReAct Agent ArchitectureUSER INPUTNatural Language Query / Task DescriptionThought (Reasoning)Analyze the queryPlan next stepsIdentify needed infoAction (Tool Call)Select appropriate toolFormat argumentsExecute tool callObservation (Result)Parse tool outputExtract key findingsUpdate contextMemory UpdateStore reasoning traceUpdate working memoryTrack token usageLoop untilcompleteDECISION: Is the answer sufficient? If yes, output Final Answer. If no, loop back to Thought.FINAL ANSWERSynthesized response with reasoning trace and confidence scoreERROR PATHTool failure β†’ retry with fallbackMax retries β†’ graceful degradationKey InsightReAct = Reasoning + ActingInterpretable trace + real-world accessPerformance3-5 iterations avg$0.05-0.15 per querySECURITY NOTE: Tool execution requires sandbox isolation. Never run untrusted code on production systems.

What is a ReAct Agent?

ReAct (Reasoning + Acting) is a paradigm that interleaves deliberation with action in large language models. Unlike pure chain-of-thought reasoning, a ReAct agent alternates between generating thoughts (reasoning about what to do next) and actions (calling tools to get real information). This loop continues until the agent has enough information to produce a final answer.

The core insight is that LLMs alone cannot access real-time data, perform calculations reliably, or interact with external systems. By pairing reasoning with tool use, ReAct agents overcome these limitations while maintaining interpretability through the thought trace.

Why This Matters

Without ReAct, LLMs are limited to their training data cutoff. A user asking "What's the current stock price of AAPL?" would receive outdated or fabricated information. ReAct agents solve this by reasoning about what tools they need, executing those tools, and synthesizing the results.

Real-world analogy: Think of a ReAct agent like a research assistant. When you ask them a question, they don't just guessβ€”they think about what information they need, look it up in references, and then formulate an answer based on what they found. The ReAct framework gives LLMs this same capability.

Key Components of ReAct

ComponentPurposeExample
ThoughtReasoning about the problem"I need to find the current stock price"
ActionTool invocationget_stock_price("AAPL")
ObservationTool result"$178.52"
DecisionContinue or finish"I have the answer, output final response"

Common Misconception

Myth: ReAct agents are just chain-of-thought with tool calls. Reality: ReAct fundamentally changes how the model reasons. In pure CoT, the model must generate all reasoning upfront. In ReAct, the model can dynamically adjust its reasoning based on tool results, leading to more accurate and grounded responses.

ReAct vs Other Paradigms

ParadigmApproachLimitation
Chain-of-ThoughtPure reasoningNo real-world access
Action-onlyTool calls onlyNo reasoning trace
ReActReasoning + ActingHigher token usage
Plan-and-ExecutePlan first, then executeLess adaptive

ReAct Loop Flow

ReAct Execution LoopStep 1: User sends query to agentStep 2: LLM generates Thought + ActionStep 3: Execute tool, get ObservationDECISION POINTAnswer complete? β†’ Output final answer | No? β†’ Loop back to Step 2ContinueLoopStep 4: Return final answer to userError Path: Tool failure β†’ capture error as Observation β†’ LLM reasons about alternative approach

Project Overview

We will build a complete ReAct agent that can:

  • Reason step-by-step about user queries using chain-of-thought
  • Select and execute appropriate tools (web search, calculator, file reader)
  • Maintain conversation context across multiple turns with working memory
  • Handle errors gracefully with retry logic and fallback strategies
  • Output structured reasoning traces for debugging and transparency
  • Track token usage and optimize for cost efficiency

Expected outcome: A production-ready agent framework you can extend with custom tools.

Difficulty: Advanced (requires Python 3.11+, OpenAI API key, understanding of async programming)

Architecture

ReAct Agent Component ArchitecturePrompt TemplateSystem instructionsTool descriptionsOutput format schemaFew-shot examplesLLM (GPT-4)Reasoning engineTool selectionArgument generationAnswer synthesisTool RegistryWeb searchCalculatorFile readerCustom toolsError HandlerRetry logicFallback toolsGraceful errorsMax retriesMemory ManagerWorking memory (current conversation)Reasoning trace historyTool call results cacheOutput ParserExtract Thought/Action/ObservationValidate tool call formatParse structured outputLoop ControllerMax iterations checkToken budget trackingTermination conditionsReAct Agent OrchestratorManages the Reasoning β†’ Action β†’ Observation loop with state managementAll components communicate through the Orchestrator which maintains the ReAct loop state machine

Tools & Setup

ToolVersionPurpose
Python3.11+Core language with async support
OpenAI API1.0+LLM backbone (GPT-4 recommended)
httpx0.27+Async HTTP client for API calls
pydantic2.0+Data validation and schema definition
rich13.0+Terminal output formatting
tiktoken0.5+Token counting for budget management

Step 1: Environment Setup

python -m venv venv
source venv/bin/activate
pip install openai httpx pydantic rich tiktoken
export OPENAI_API_KEY="sk-your-key"

Step 2: Project Structure

# project structure:
# react_agent/
#   __init__.py
#   agent.py          # Main ReAct agent class
#   tools.py          # Tool definitions and registry
#   memory.py         # Working memory management
#   parser.py         # Output parsing logic
#   prompts.py        # Prompt templates
#   config.py         # Configuration settings
#   logger.py         # Structured logging
# tests/
#   test_agent.py     # Unit tests
#   test_tools.py     # Tool tests
# main.py             # Entry point

Step 3: Core Data Models

# models.py
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from enum import Enum
from datetime import datetime
import logging

logger = logging.getLogger(__name__)


class ActionType(str, Enum):
    THOUGHT = "thought"
    ACTION = "action"
    OBSERVATION = "observation"
    ANSWER = "answer"


class ToolCall(BaseModel):
    """Represents a single tool invocation with its results."""
    name: str
    arguments: Dict[str, Any]
    result: Optional[str] = None
    error: Optional[str] = None
    timestamp: datetime = Field(default_factory=datetime.now)
    execution_time_ms: float = 0.0

    def is_successful(self) -> bool:
        return self.error is None and self.result is not None


class ReActStep(BaseModel):
    """A single step in the ReAct reasoning trace."""
    step_number: int
    action_type: ActionType
    content: str
    tool_call: Optional[ToolCall] = None
    tokens_used: int = 0
    timestamp: datetime = Field(default_factory=datetime.now)


class AgentConfig(BaseModel):
    """Configuration for the ReAct agent."""
    model: str = "gpt-4-turbo-preview"
    max_iterations: int = 10
    max_tokens: int = 4000
    temperature: float = 0.0
    timeout: float = 30.0
    max_retries: int = 3
    log_level: str = "INFO"


class AgentResult(BaseModel):
    """Result of a complete ReAct agent execution."""
    success: bool
    answer: str
    steps: List[ReActStep]
    total_tokens: int
    total_time: float
    iterations: int
    error: Optional[str] = None

    @property
    def avg_tokens_per_step(self) -> float:
        return self.total_tokens / max(self.iterations, 1)

    @property
    def cost_estimate(self) -> float:
        """Estimate cost based on GPT-4 pricing."""
        input_tokens = sum(s.tokens_used for s in self.steps if s.action_type != ActionType.OBSERVATION)
        output_tokens = sum(s.tokens_used for s in self.steps if s.action_type == ActionType.OBSERVATION)
        return (input_tokens * 0.01 + output_tokens * 0.03) / 1000

Step 4: Tool Registry

# tools.py
from typing import Callable, Any, Dict, List, Optional
from dataclasses import dataclass, field
import json
import logging
import time

logger = logging.getLogger(__name__)


@dataclass
class Tool:
    """Represents a callable tool with metadata."""
    name: str
    description: str
    function: Callable
    parameters: Dict[str, Any] = field(default_factory=dict)
    is_async: bool = False
    timeout: float = 30.0
    max_retries: int = 3
    last_error: Optional[str] = None
    error_count: int = 0

    def record_error(self, error: str) -> None:
        self.last_error = error
        self.error_count += 1
        logger.warning(f"Tool '{self.name}' error: {error}")


class ToolRegistry:
    """Registry for managing and executing tools with validation."""

    def __init__(self):
        self._tools: Dict[str, Tool] = {}

    def register(self, tool: Tool) -> None:
        """Register a tool with validation."""
        if tool.name in self._tools:
            logger.warning(f"Overwriting existing tool: {tool.name}")
        self._tools[tool.name] = tool
        logger.info(f"Registered tool: {tool.name}")

    def get_tool(self, name: str) -> Tool:
        """Get a tool by name, raising ValueError if not found."""
        if name not in self._tools:
            raise ValueError(f"Tool '{name}' not found. Available: {list(self._tools.keys())}")
        return self._tools[name]

    def list_tools(self) -> List[Dict[str, Any]]:
        """List all registered tools with their schemas."""
        return [
            {
                "name": t.name,
                "description": t.description,
                "parameters": t.parameters,
                "error_count": t.error_count,
            }
            for t in self._tools.values()
        ]

    def execute(self, name: str, arguments: Dict[str, Any]) -> str:
        """Execute a tool with error handling and logging."""
        tool = self.get_tool(name)
        start_time = time.monotonic()
        try:
            result = tool.function(**arguments)
            execution_time = (time.monotonic() - start_time) * 1000
            logger.info(f"Tool '{name}' executed in {execution_time:.1f}ms")
            return str(result)
        except Exception as e:
            execution_time = (time.monotonic() - start_time) * 1000
            tool.record_error(str(e))
            logger.error(f"Tool '{name}' failed after {execution_time:.1f}ms: {e}")
            return f"Error executing {name}: {str(e)}"

    def get_tools_description(self) -> str:
        """Get formatted tool descriptions for prompt injection."""
        tools_json = json.dumps(self.list_tools(), indent=2)
        return f"Available tools:\n{tools_json}"

    def get_healthy_tools(self) -> List[str]:
        """Get tools that haven't exceeded error threshold."""
        return [name for name, tool in self._tools.items() if tool.error_count < 5]


# Built-in tools with proper error handling
def web_search(query: str, num_results: int = 5) -> str:
    """Search the web for current information."""
    if not query or len(query.strip()) < 2:
        return "Error: Query too short"
    # Placeholder - replace with actual search API
    return f"Search results for '{query}': [Result 1], [Result 2], [Result 3]"


def calculator(expression: str) -> str:
    """Evaluate a mathematical expression safely."""
    try:
        # Safe evaluation with restricted builtins
        result = eval(expression, {"__builtins__": {}}, {
            "abs": abs, "round": round, "min": min, "max": max,
            "sum": sum, "len": len, "int": int, "float": float,
        })
        return str(result)
    except ZeroDivisionError:
        return "Error: Division by zero"
    except Exception as e:
        return f"Calculation error: {str(e)}"


def read_file(file_path: str, max_chars: int = 5000) -> str:
    """Read the contents of a file with size limit."""
    try:
        from pathlib import Path
        path = Path(file_path)
        if not path.exists():
            return f"Error: File not found: {file_path}"
        if not path.is_file():
            return f"Error: Not a file: {file_path}"
        content = path.read_text(encoding="utf-8", errors="ignore")
        if len(content) > max_chars:
            return content[:max_chars] + f"\n... (truncated at {max_chars} chars)"
        return content
    except PermissionError:
        return f"Error: Permission denied: {file_path}"
    except Exception as e:
        return f"File read error: {str(e)}"


def create_default_tools() -> ToolRegistry:
    """Create a registry with default tools."""
    registry = ToolRegistry()
    registry.register(Tool(
        name="web_search",
        description="Search the web for current information",
        function=web_search,
        parameters={
            "query": {"type": "string", "description": "Search query"},
            "num_results": {"type": "integer", "description": "Number of results to return", "default": 5},
        },
    ))
    registry.register(Tool(
        name="calculator",
        description="Calculate mathematical expressions",
        function=calculator,
        parameters={
            "expression": {"type": "string", "description": "Math expression to evaluate"},
        },
    ))
    registry.register(Tool(
        name="read_file",
        description="Read file contents",
        function=read_file,
        parameters={
            "file_path": {"type": "string", "description": "Path to file"},
            "max_chars": {"type": "integer", "description": "Max characters to read", "default": 5000},
        },
    ))
    return registry

Step 5: Memory Manager

# memory.py
from typing import List, Dict, Optional
from models import ReActStep, ToolCall, ActionType
import tiktoken
import logging

logger = logging.getLogger(__name__)


class WorkingMemory:
    """Manages the agent's working memory with token budget enforcement."""

    def __init__(self, max_tokens: int = 8000):
        self.max_tokens = max_tokens
        self.steps: List[ReActStep] = []
        self.enc = tiktoken.get_encoding("cl100k_base")
        logger.info(f"WorkingMemory initialized with {max_tokens} token budget")

    def add_step(self, step: ReActStep) -> None:
        """Add a step and trim if necessary."""
        self.steps.append(step)
        initial_count = len(self.steps)
        self._trim_if_needed()
        if len(self.steps) < initial_count:
            logger.info(f"Trimmed {initial_count - len(self.steps)} old steps")

    def _trim_if_needed(self) -> None:
        """Remove oldest steps when token budget exceeded."""
        while self._count_tokens() > self.max_tokens and len(self.steps) > 1:
            self.steps.pop(0)

    def _count_tokens(self) -> int:
        """Count total tokens in memory."""
        text = self.get_context_string()
        return len(self.enc.encode(text))

    def get_context_string(self) -> str:
        """Format memory as context string for LLM."""
        parts = []
        for step in self.steps:
            if step.action_type == ActionType.THOUGHT:
                parts.append(f"Thought: {step.content}")
            elif step.action_type == ActionType.ACTION:
                tool_name = step.tool_call.name if step.tool_call else "unknown"
                args = step.tool_call.arguments if step.tool_call else {}
                parts.append(f"Action: {tool_name}({json.dumps(args, default=str)})")
            elif step.action_type == ActionType.OBSERVATION:
                parts.append(f"Observation: {step.content}")
            elif step.action_type == ActionType.ANSWER:
                parts.append(f"Answer: {step.content}")
        return "\n".join(parts)

    def get_recent_observations(self, n: int = 3) -> List[str]:
        """Get the most recent N observations."""
        observations = [
            s.content for s in self.steps
            if s.action_type == ActionType.OBSERVATION
        ]
        return observations[-n:]

    def clear(self) -> None:
        """Clear all memory."""
        self.steps.clear()
        logger.debug("Memory cleared")

    def get_step_count(self) -> int:
        return len(self.steps)

    def get_total_tokens(self) -> int:
        return sum(s.tokens_used for s in self.steps)

Step 6: Output Parser

# parser.py
import re
import json
from typing import Optional, Tuple
from models import ActionType, ToolCall
import logging

logger = logging.getLogger(__name__)


class ReActParser:
    """Parses LLM output into structured Thought/Action/Observation/Answer."""

    THOUGHT_PATTERN = r"Thought:\s*(.+?)(?=\nAction:|\nAnswer:|$)"
    ACTION_PATTERN = r"Action:\s*(\w+)\((.+?)\)"
    ANSWER_PATTERN = r"Answer:\s*(.+)"

    def parse(self, text: str) -> Tuple[ActionType, str, Optional[ToolCall]]:
        """Parse LLM output into action type, content, and optional tool call."""
        if not text or not text.strip():
            logger.warning("Empty LLM output")
            return ActionType.THOUGHT, "I need to think about this more.", None

        thought_match = re.search(self.THOUGHT_PATTERN, text, re.DOTALL)
        if thought_match:
            thought = thought_match.group(1).strip()
            action_match = re.search(self.ACTION_PATTERN, text, re.DOTALL)
            if action_match:
                tool_name = action_match.group(1)
                try:
                    args_str = action_match.group(2)
                    args = self._parse_arguments(args_str)
                    tool_call = ToolCall(name=tool_name, arguments=args)
                    return ActionType.THOUGHT, thought, tool_call
                except Exception as e:
                    logger.warning(f"Failed to parse tool arguments: {e}")
            return ActionType.THOUGHT, thought, None

        answer_match = re.search(self.ANSWER_PATTERN, text, re.DOTALL)
        if answer_match:
            return ActionType.ANSWER, answer_match.group(1).strip(), None

        return ActionType.THOUGHT, text.strip(), None

    def _parse_arguments(self, args_str: str) -> dict:
        """Parse tool arguments from string format."""
        args_str = args_str.strip()
        try:
            return json.loads(args_str)
        except json.JSONDecodeError:
            if "=" in args_str:
                return self._parse_kwargs(args_str)
            return {"input": args_str}

    def _parse_kwargs(self, args_str: str) -> dict:
        """Parse keyword arguments from string."""
        result = {}
        for pair in args_str.split(","):
            if "=" in pair:
                key, value = pair.split("=", 1)
                value = value.strip().strip("'\"")
                result[key.strip()] = value
        return result

Step 7: Prompt Templates

# prompts.py
from typing import List, Optional

SYSTEM_PROMPT = """You are a ReAct agent that solves problems by reasoning step-by-step and using tools when needed.

For each problem, follow this exact format:

Thought: [Your reasoning about what to do next]
Action: [tool_name]([arguments])
Observation: [This will be filled in after tool execution]
... (repeat Thought/Action/Observation as needed)
Thought: I now have enough information to answer
Answer: [Your final answer]

Rules:
1. Always start with a Thought
2. Use only available tools
3. Wait for Observation before continuing
4. Provide final Answer when ready
5. Be concise and accurate
6. If a tool fails, try an alternative approach
7. Never fabricate tool results"""


def build_prompt(
    query: str,
    tools_description: str,
    memory_context: str,
    step_number: int,
    max_iterations: int,
    error_context: Optional[str] = None,
) -> str:
    """Build the full prompt for the LLM."""
    prompt = f"{SYSTEM_PROMPT}\n\n"
    prompt += f"{tools_description}\n\n"
    prompt += f"Current step: {step_number}/{max_iterations}\n\n"

    if memory_context:
        prompt += f"Previous reasoning:\n{memory_context}\n\n"

    if error_context:
        prompt += f"Previous error context:\n{error_context}\n\n"

    prompt += f"User query: {query}\n\n"
    prompt += "Begin your reasoning:\n"

    return prompt

Step 8: Main Agent

# agent.py
import time
import logging
from typing import Optional
from openai import OpenAI
from models import (
    ReActStep, AgentConfig, AgentResult,
    ActionType, ToolCall
)
from tools import ToolRegistry, create_default_tools
from memory import WorkingMemory
from parser import ReActParser
from prompts import build_prompt

logger = logging.getLogger(__name__)


class ReActAgent:
    """A production-ready ReAct agent with error handling and logging."""

    def __init__(
        self,
        config: Optional[AgentConfig] = None,
        tools: Optional[ToolRegistry] = None,
    ):
        self.config = config or AgentConfig()
        self.tools = tools or create_default_tools()
        self.memory = WorkingMemory(max_tokens=self.config.max_tokens)
        self.parser = ReActParser()
        self.client = OpenAI()
        self.step_counter = 0
        logger.info(f"ReActAgent initialized with model={self.config.model}")

    def run(self, query: str) -> AgentResult:
        """Execute the ReAct loop for a given query."""
        start_time = time.time()
        self.memory.clear()
        self.step_counter = 0
        last_error = None

        logger.info(f"Starting ReAct execution for query: {query[:100]}...")

        try:
            while self.step_counter < self.config.max_iterations:
                self.step_counter += 1
                prompt = self._build_prompt(query, last_error)

                response = self.client.chat.completions.create(
                    model=self.config.model,
                    messages=[{"role": "user", "content": prompt}],
                    temperature=self.config.temperature,
                    max_tokens=self.config.max_tokens,
                )

                output = response.choices[0].message.content
                tokens_used = response.usage.total_tokens

                action_type, content, tool_call = self.parser.parse(output)

                if action_type == ActionType.ANSWER:
                    step = ReActStep(
                        step_number=self.step_counter,
                        action_type=ActionType.ANSWER,
                        content=content,
                        tokens_used=tokens_used,
                    )
                    self.memory.add_step(step)
                    break

                if tool_call:
                    step = ReActStep(
                        step_number=self.step_counter,
                        action_type=ActionType.THOUGHT,
                        content=content,
                        tool_call=tool_call,
                        tokens_used=tokens_used,
                    )
                    self.memory.add_step(step)

                    # Execute tool with retry logic
                    result = self._execute_tool_with_retry(tool_call)
                    tool_call.result = result

                    obs_step = ReActStep(
                        step_number=self.step_counter,
                        action_type=ActionType.OBSERVATION,
                        content=result,
                    )
                    self.memory.add_step(obs_step)

                    # Track errors for context
                    if result.startswith("Error"):
                        last_error = result
                    else:
                        last_error = None
                else:
                    step = ReActStep(
                        step_number=self.step_counter,
                        action_type=ActionType.THOUGHT,
                        content=content,
                        tokens_used=tokens_used,
                    )
                    self.memory.add_step(step)

        except Exception as e:
            logger.error(f"Agent execution failed: {e}")
            return AgentResult(
                success=False,
                answer=f"Agent encountered an error: {str(e)}",
                steps=self.memory.steps,
                total_tokens=self.memory.get_total_tokens(),
                total_time=time.time() - start_time,
                iterations=self.step_counter,
                error=str(e),
            )

        total_time = time.time() - start_time
        final_answer = self._get_final_answer()

        logger.info(
            f"Execution complete: {self.step_counter} steps, "
            f"{self.memory.get_total_tokens()} tokens, "
            f"{total_time:.2f}s"
        )

        return AgentResult(
            success=True,
            answer=final_answer,
            steps=self.memory.steps,
            total_tokens=self.memory.get_total_tokens(),
            total_time=total_time,
            iterations=self.step_counter,
        )

    def _execute_tool_with_retry(self, tool_call: ToolCall) -> str:
        """Execute a tool with retry logic."""
        last_error = None
        for attempt in range(self.config.max_retries):
            result = self.tools.execute(tool_call.name, tool_call.arguments)
            if not result.startswith("Error"):
                return result
            last_error = result
            logger.warning(f"Tool attempt {attempt + 1} failed: {result}")
        return last_error

    def _build_prompt(self, query: str, error_context: Optional[str] = None) -> str:
        """Build the prompt for the current step."""
        tools_desc = self.tools.get_tools_description()
        memory_context = self.memory.get_context_string()
        return build_prompt(
            query=query,
            tools_description=tools_desc,
            memory_context=memory_context,
            step_number=self.step_counter,
            max_iterations=self.config.max_iterations,
            error_context=error_context,
        )

    def _get_final_answer(self) -> str:
        """Extract the final answer from memory."""
        for step in reversed(self.memory.steps):
            if step.action_type == ActionType.ANSWER:
                return step.content
        return "Max iterations reached without final answer."

Step 9: Entry Point

# main.py
import logging
from agent import ReActAgent
from models import AgentConfig
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table

logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")


def main():
    console = Console()
    config = AgentConfig(model="gpt-4-turbo-preview", max_iterations=10)
    agent = ReActAgent(config=config)

    console.print("[bold blue]ReAct Agent Ready![/bold blue]")
    console.print("Type your query or 'quit' to exit.\n")

    while True:
        try:
            query = console.input("[bold green]You:[/bold green] ")
            if query.lower() in ("quit", "exit", "q"):
                break

            result = agent.run(query)

            console.print("\n[bold yellow]Agent Response:[/bold yellow]")
            console.print(Markdown(result.answer))

            # Show metrics table
            table = Table(title="Execution Metrics")
            table.add_column("Metric", style="cyan")
            table.add_column("Value", style="green")
            table.add_row("Steps", str(result.iterations))
            table.add_row("Total Tokens", str(result.total_tokens))
            table.add_row("Total Time", f"{result.total_time:.2f}s")
            table.add_row("Est. Cost", f"${result.cost_estimate:.4f}")
            console.print(table)
            console.print()

        except KeyboardInterrupt:
            break
        except Exception as e:
            console.print(f"[bold red]Error:[/bold red] {e}")


if __name__ == "__main__":
    main()

Mathematical Foundation

Token Budget Management:

Where:

  • β€” System prompt tokens (~500-1000)
  • β€” Tokens in i-th thought (~100-300)
  • β€” Tokens in i-th action (~50-150)
  • β€” Tokens in i-th observation (~200-1000)
  • β€” Number of iterations

Real-world example: A typical query "What's the weather in NYC and what's 2+2?" might use:

  • System: 800 tokens
  • Thought 1: 150 tokens
  • Action 1: 80 tokens
  • Observation 1: 300 tokens
  • Thought 2: 120 tokens
  • Action 2: 60 tokens
  • Observation 2: 100 tokens
  • Answer: 100 tokens
  • Total: ~1,710 tokens β†’ ~$0.05 per query

Cost Calculation:

Where:

  • β€” Input token price (e.g., $10/1M for GPT-4)
  • β€” Output token price (e.g., $30/1M for GPT-4)

Performance Metrics

MetricValueNotes
Avg Iterations3-5For typical queries
Tokens per Query2000-4000Including tool results
Latency5-15sDependent on tools
Accuracy85%+With GPT-4 backbone
Cost per Query$0.05-0.15GPT-4 pricing
First-try Success70%+Without retries

Security Considerations

  1. Tool Isolation: Run tools in sandboxed environments (Docker containers)
  2. Input Validation: Sanitize all tool arguments to prevent injection attacks
  3. Rate Limiting: Implement rate limits on tool calls to prevent abuse
  4. Audit Logging: Log all tool executions for security review
  5. API Key Protection: Never expose API keys in logs or error messages
  6. Timeout Enforcement: Set strict timeouts on all tool executions
  7. Output Sanitization: Filter tool outputs before adding to LLM context

Why This Matters (Deep Explanation)

The ReAct framework is foundational because it solves the grounding problem in LLMs. Without tools, LLMs can only generate text based on their training dataβ€”they cannot access current information, perform calculations, or interact with external systems.

Real-world analogy: Imagine asking a librarian a question. Without ReAct, the librarian would try to answer from memory alone. With ReAct, the librarian thinks about what resources they need, looks them up in the catalog, retrieves the books, reads the relevant sections, and then formulates an answer based on what they found.

Common misconception: "ReAct is just chain-of-thought with extra steps." Reality: ReAct fundamentally changes the reasoning process. The model can dynamically adjust its strategy based on tool results, leading to more accurate and grounded responses.

Interview Questions

1. What is the key difference between ReAct and chain-of-thought reasoning?

Answer: Chain-of-thought (CoT) reasoning generates a step-by-step thought process but cannot access external information. ReAct extends CoT by interleaving thoughts with actions (tool calls), allowing the agent to gather real-time data, perform calculations, and interact with external systems. The key advantage is that ReAct produces both an interpretable reasoning trace AND accurate results from real-world tools.

# CoT: Can only reason from training data
cot = "The capital of France is Paris. Paris has 2.1 million people..."

# ReAct: Can verify and gather real information
react = [
    Thought("I need to find current population of Paris"),
    Action("web_search", {"query": "Paris population 2024"}),
    Observation("Paris population: 2.1 million (2024)"),
    Answer("Paris has approximately 2.1 million people")
]

2. How does the ReAct loop handle tool failures?

Answer: When a tool fails, the ReAct loop: 1) Captures the error message in the Observation, 2) The LLM sees the error and reasons about alternatives in the next Thought, 3) It may retry with different arguments, use a different tool, or acknowledge the limitation. This graceful degradation is a key advantage over rigid pipeline approaches.

# Example error handling in the loop
Thought("I need to calculate sqrt(-1), but that's invalid")
Action("calculator", {"expression": "(-1) ** 0.5"})
Observation("Error: math domain error")
Thought("Complex numbers aren't supported. Let me explain the limitation.")
Answer("The square root of -1 is not a real number. In complex numbers, it's i.")

3. What is the token budget problem in ReAct agents?

Answer: Each iteration adds tokens for the thought, action, and observation to the prompt context. As the conversation grows, the prompt approaches the model's context window limit. Solutions include: 1) Sliding window memory (keep only recent steps), 2) Summarization (compress old steps), 3) Token budget tracking (warn when approaching limits), 4) Early termination (stop if budget exceeded). The WorkingMemory class demonstrates this with automatic trimming.

4. How would you implement async tool execution in a ReAct agent?

Answer: Use Python's asyncio with httpx for concurrent tool calls:

import asyncio
from typing import List

async def execute_tools_concurrently(tools: List[dict]) -> List[str]:
    """Execute multiple tools in parallel when they're independent."""
    tasks = []
    for tool_call in tools:
        task = asyncio.create_call(tool_call["name"], tool_call["args"])
        tasks.append(task)
    return await asyncio.gather(*tasks, return_exceptions=True)

This reduces latency when multiple independent tools need to be called. For example, if an agent needs to search the web AND calculate something, these can run in parallel.

5. What are the limitations of ReAct agents?

Answer: Key limitations: 1) Token cost β€” Each iteration adds context tokens, 2) Latency β€” Multiple LLM calls increase response time, 3) Hallucination risk β€” LLM may generate invalid tool calls, 4) Max iterations β€” Complex problems may exceed limits, 5) Tool availability β€” Limited to registered tools, 6) Error propagation β€” Wrong observations can mislead reasoning.

6. How do you evaluate ReAct agent performance?

Answer: Key metrics: 1) Task completion rate β€” % of queries answered correctly, 2) Step efficiency β€” Average iterations to complete, 3) Token efficiency β€” Tokens used per successful query, 4) Latency β€” End-to-end response time, 5) Cost per query β€” Dollar cost, 6) Tool accuracy β€” % of successful tool calls, 7) Reasoning quality β€” Human evaluation of thought traces.

7. What is the role of the output parser in ReAct?

Answer: The output parser extracts structured information from the LLM's free-text output: 1) Identifies the action type (Thought, Action, Answer), 2) Extracts tool name and arguments from Action lines, 3) Validates the format matches expectations, 4) Handles malformed output gracefully. Robust parsing is critical because LLMs may deviate from the expected format.

8. How would you extend a ReAct agent with custom tools?

Answer: Steps to add a custom tool:

# 1. Create a function implementing the tool logic
def get_stock_price(symbol: str) -> str:
    """Get current stock price from Yahoo Finance."""
    import yfinance as yf
    stock = yf.Ticker(symbol)
    return str(stock.info.get("currentPrice", "N/A"))

# 2. Register it with the ToolRegistry
registry.register(Tool(
    name="get_stock_price",
    description="Get current stock price for a given ticker symbol",
    function=get_stock_price,
    parameters={"symbol": {"type": "string", "description": "Stock ticker (e.g., AAPL)"}},
))

# 3. The tool is now available for the agent to use

The tool's description is criticalβ€”it must clearly explain when and how to use it.

Common Pitfalls & Solutions

PitfallSolution
Infinite loopsSet max_iterations and token budgets
Hallucinated tool callsValidate tool names against registry
Context window overflowImplement sliding window memory
High latencyCache frequent tool results
Cost explosionTrack tokens and set spending limits
Inconsistent outputUse structured output parsing
Error propagationAdd validation between steps
Tool description ambiguityWrite clear, specific tool descriptions

Performance Optimization

Caching Strategy

from functools import lru_cache
import hashlib

@lru_cache(maxsize=100)
def cached_web_search(query: str) -> str:
    """Cache web search results to avoid redundant API calls."""
    return web_search(query)

Token Budget Monitoring

def check_budget(memory: WorkingMemory, max_tokens: int = 8000) -> bool:
    """Check if we're approaching token limits."""
    current = memory._count_tokens()
    if current > max_tokens * 0.8:
        logger.warning(f"Token budget at {current/max_tokens*100:.1f}%")
        return True
    return False

Summary with Key Takeaways

  • ReAct interleaves reasoning (Thought) with action (Tool calls) for interpretable, effective agents
  • The Thought β†’ Action β†’ Observation loop continues until the agent has sufficient information
  • Working memory manages context to stay within token limits while preserving relevant history
  • Tool registry provides a clean abstraction for adding new capabilities
  • Output parsing extracts structured information from free-text LLM output
  • Always implement max iterations and token budgets to control cost and prevent infinite loops
  • Error handling with retry logic and fallback strategies is essential for production use

KnowledgeCheck

  1. What does "ReAct" stand for in the context of AI agents?

    • a) React.js framework for agents
    • b) Reasoning + Acting
    • c) Real-time Action
    • d) Reactive Architecture
  2. What is the correct order in a ReAct loop iteration?

    • a) Action β†’ Thought β†’ Observation
    • b) Observation β†’ Action β†’ Thought
    • c) Thought β†’ Action β†’ Observation
    • d) Thought β†’ Observation β†’ Action
  3. Why is working memory important in ReAct agents?

    • a) It stores user credentials
    • b) It manages token budget and prevents context overflow
    • c) It speeds up LLM inference
    • d) It replaces the need for tools
  4. What happens when a tool call fails in a well-designed ReAct agent?

    • a) The agent crashes immediately
    • b) The error is captured as an Observation and the agent reasons about alternatives
    • c) The agent ignores the error and continues
    • d) The agent asks the user to fix the tool
  5. What is the primary purpose of the output parser?

    • a) To generate the system prompt
    • b) To execute tool calls
    • c) To extract structured information from LLM free-text output
    • d) To manage token usage
  6. How does ReAct differ from pure chain-of-thought (CoT) reasoning?

    • a) ReAct is faster
    • b) ReAct can access external tools and real-world data
    • c) ReAct uses smaller models
    • d) ReAct doesn't require an LLM

Answers: 1-b, 2-c, 3-b, 4-b, 5-c, 6-b

Need Expert AI Agents Help?

Get personalized tutoring, project support, or professional consulting.

Advertisement