HinterBuild logoHinterBuild
AI Systems · 12 min read

ReAct vs Plan-and-Execute: Agent Reasoning Patterns Compared

Learn react vs plan-and-execute through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· 12 min read

  • AI Agents
  • Tool Calling
  • LangGraph
  • Architecture

AI agent reasoning patterns determine how agents break down problems and sequence actions. ReAct (Reasoning + Acting) interleaves thought and action in tight loops, while Plan-and-Execute separates planning from execution with distinct phases. This guide compares both patterns with production benchmarks and implementation guidance.

Key Takeaways:

  • Treat ReAct vs Plan-and-Execute as a system with an explicit input and output contract.
  • Benchmark a representative baseline before choosing an optimization.
  • Bound retries, queues, concurrency, and total request deadlines.
  • Roll out through offline replay, shadow traffic, and a measurable canary.
  • Keep rollback simple and attach version identifiers to every decision.

Table of Contents:

Reasoning Patterns Overview

Agent reasoning patterns define the cognitive loop agents use to accomplish tasks:

ReAct (Reasoning + Acting)

Pattern: Thought → Action → Observation → Thought → Action...

Characteristics:

  • Tight feedback loop
  • Reactive to observations
  • Flexible and adaptive
  • Higher LLM call count

Use cases: Dynamic environments, user interaction, exploratory tasks

Plan-and-Execute

Pattern: Plan → Execute Step 1 → Execute Step 2 → ... → Verify

Characteristics:

  • Upfront planning phase
  • Sequential execution
  • Predictable behavior
  • Lower LLM call count

Use cases: Well-defined workflows, batch processing, cost-sensitive operations

Both patterns are fundamental to production AI agent systems.

ReAct Pattern Deep Dive

ReAct agents think, act, observe, and repeat until the task is complete:

python
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import tool

llm = ChatOpenAI(model="gpt-4")

@tool
async def search_database(query: str) -> dict:
    """Search database for information."""
    return {"results": [...]}

@tool
async def calculate(expression: str) -> float:
    """Perform calculation."""
    return eval(expression)  # Sandboxed in production

REACT_PROMPT = """You solve problems by thinking step-by-step and using tools.

Available tools:
{tools}

Use this format:

Thought: [Your reasoning about what to do next]
Action: [Tool name]
Action Input: [Tool input]
Observation: [Tool output will appear here]

... repeat Thought/Action/Observation as needed ...

Thought: I now know the final answer
Final Answer: [Your answer]

Task: {task}

Begin!"""

async def react_agent(task: str, max_iterations: int = 10):
    """ReAct agent implementation."""
    
    tools = [search_database, calculate]
    tool_map = {tool.name: tool for tool in tools}
    
    prompt = ChatPromptTemplate.from_template(REACT_PROMPT)
    
    # Initialize conversation
    messages = []
    tool_descriptions = "\n".join([
        f"- {tool.name}: {tool.description}" for tool in tools
    ])
    
    current_text = prompt.format(tools=tool_descriptions, task=task)
    
    for iteration in range(max_iterations):
        # Reasoning step
        response = await llm.ainvoke([
            {"role": "system", "content": "You are a helpful assistant."},
            {"role": "user", "content": current_text}
        ])
        
        response_text = response.content
        current_text += f"\n{response_text}"
        
        # Check for final answer
        if "Final Answer:" in response_text:
            final_answer = response_text.split("Final Answer:")[-1].strip()
            return {
                "answer": final_answer,
                "iterations": iteration + 1,
                "reasoning_trace": current_text
            }
        
        # Extract action
        if "Action:" in response_text and "Action Input:" in response_text:
            action_block = response_text.split("Action:")[1].split("Action Input:")
            action_name = action_block[0].strip()
            action_input = action_block[1].split("\n")[0].strip()
            
            # Execute tool
            if action_name in tool_map:
                try:
                    result = await tool_map[action_name].ainvoke(action_input)
                    observation = f"\nObservation: {result}"
                except Exception as e:
                    observation = f"\nObservation: Error executing {action_name}: {str(e)}"
            else:
                observation = f"\nObservation: Unknown tool {action_name}"
            
            current_text += observation
        else:
            # No valid action found - prompt for proper format
            current_text += "\n\nRemember to use the Thought/Action/Action Input format."
    
    return {
        "answer": "Max iterations reached without final answer",
        "iterations": max_iterations,
        "reasoning_trace": current_text
    }

# Usage
result = await react_agent(
    "Find the total revenue for Q2 2026 and calculate the 15% tax"
)

ReAct with LangGraph

python
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import create_react_agent
from typing import TypedDict, Annotated
import operator

class ReactState(TypedDict):
    """State for ReAct agent."""
    messages: Annotated[list, operator.add]
    iteration: int

def create_react_graph():
    """ReAct agent as LangGraph."""
    
    # Use prebuilt ReAct agent
    react_agent = create_react_agent(
        llm,
        tools=[search_database, calculate],
        state_modifier="You are a helpful assistant solving tasks step-by-step."
    )
    
    return react_agent

# Or build custom ReAct graph
def create_custom_react_graph():
    """Custom ReAct implementation with explicit control."""
    
    workflow = StateGraph(ReactState)
    
    def reasoning_node(state: ReactState):
        """Think about next action."""
        response = llm.invoke(state["messages"])
        return {
            "messages": [response],
            "iteration": state.get("iteration", 0) + 1
        }
    
    def tool_execution_node(state: ReactState):
        """Execute tools based on reasoning."""
        last_message = state["messages"][-1]
        
        # Parse tool calls from message
        tool_calls = extract_tool_calls(last_message)
        
        results = []
        for call in tool_calls:
            result = execute_tool(call["name"], call["args"])
            results.append({
                "role": "tool",
                "content": str(result),
                "tool_call_id": call["id"]
            })
        
        return {"messages": results}
    
    def should_continue(state: ReactState) -> str:
        """Decide if more iterations needed."""
        last_message = state["messages"][-1]
        
        # Check for final answer
        if "final answer" in last_message.content.lower():
            return "end"
        
        # Check iteration limit
        if state.get("iteration", 0) >= 10:
            return "end"
        
        # Check for tool calls
        if extract_tool_calls(last_message):
            return "execute_tools"
        
        return "reason"
    
    workflow.add_node("reasoning", reasoning_node)
    workflow.add_node("tool_execution", tool_execution_node)
    
    workflow.add_conditional_edges(
        "reasoning",
        should_continue,
        {
            "execute_tools": "tool_execution",
            "reason": "reasoning",
            "end": END
        }
    )
    
    workflow.add_edge("tool_execution", "reasoning")
    workflow.set_entry_point("reasoning")
    
    return workflow.compile()

ReAct Advantages

Adaptive to feedback:

  • Adjusts strategy based on observations
  • Recovers from failed tool calls
  • Explores alternative approaches

Handles uncertainty:

  • Works well with incomplete information
  • Iterative refinement of understanding

User interaction:

  • Natural for back-and-forth conversations
  • Can ask clarifying questions

ReAct Challenges

Higher cost:

  • Multiple LLM calls per task
  • Token usage grows with iterations

Unpredictable behavior:

  • Path through problem varies
  • Difficult to estimate completion time

Loop risk:

Plan-and-Execute Pattern

Plan-and-Execute agents create complete plans upfront, then execute steps sequentially:

python
from typing import List, Dict
from dataclasses import dataclass

@dataclass
class Step:
    """Single execution step."""
    id: int
    description: str
    tool: str
    args: Dict
    dependencies: List[int]  # Step IDs that must complete first

class PlanExecuteAgent:
    """Plan-and-Execute agent implementation."""
    
    def __init__(self, llm, tools):
        self.llm = llm
        self.tools = {tool.name: tool for tool in tools}
    
    async def create_plan(self, task: str) -> List[Step]:
        """Planning phase - create complete plan."""
        
        planning_prompt = f"""Create a detailed plan to accomplish this task:
        
        Task: {task}
        
        Available tools:
        {self._format_tools()}
        
        Output a JSON list of steps:
        [
          {{
            "id": 1,
            "description": "Step description",
            "tool": "tool_name",
            "args": {{"arg1": "value1"}},
            "dependencies": []
          }},
          ...
        ]
        
        Ensure steps are in logical order with dependencies specified."""
        
        response = await self.llm.ainvoke([
            {"role": "system", "content": "You are an expert planner."},
            {"role": "user", "content": planning_prompt}
        ])
        
        # Parse JSON plan
        plan_json = extract_json(response.content)
        
        plan = [
            Step(
                id=step["id"],
                description=step["description"],
                tool=step["tool"],
                args=step["args"],
                dependencies=step.get("dependencies", [])
            )
            for step in plan_json
        ]
        
        return plan
    
    async def execute_plan(self, plan: List[Step]) -> Dict:
        """Execution phase - run steps in order."""
        
        results = {}
        
        # Topological sort for dependency order
        sorted_steps = self._topological_sort(plan)
        
        for step in sorted_steps:
            # Wait for dependencies
            for dep_id in step.dependencies:
                if dep_id not in results:
                    raise ValueError(f"Dependency {dep_id} not satisfied for step {step.id}")
            
            logger.info(
                "Executing step",
                extra={
                    "step_id": step.id,
                    "description": step.description
                }
            )
            
            # Execute step
            try:
                # Inject dependency results into args if needed
                args = self._resolve_dependencies(step.args, results)
                
                result = await self.tools[step.tool].ainvoke(args)
                results[step.id] = {
                    "step": step.description,
                    "result": result,
                    "status": "success"
                }
            
            except Exception as e:
                logger.error(
                    "Step failed",
                    extra={"step_id": step.id, "error": str(e)}
                )
                results[step.id] = {
                    "step": step.description,
                    "error": str(e),
                    "status": "failed"
                }
                
                # Abort on failure
                return {
                    "status": "failed",
                    "completed_steps": len(results),
                    "total_steps": len(plan),
                    "results": results
                }
        
        return {
            "status": "success",
            "completed_steps": len(results),
            "total_steps": len(plan),
            "results": results
        }
    
    async def run(self, task: str) -> Dict:
        """Full plan-and-execute cycle."""
        
        # Planning phase
        logger.info("Planning phase started")
        plan = await self.create_plan(task)
        logger.info(f"Plan created with {len(plan)} steps")
        
        # Execution phase
        logger.info("Execution phase started")
        results = await self.execute_plan(plan)
        
        return {
            "task": task,
            "plan": [{"id": s.id, "description": s.description} for s in plan],
            **results
        }
    
    def _topological_sort(self, plan: List[Step]) -> List[Step]:
        """Sort steps by dependencies."""
        sorted_steps = []
        remaining = plan.copy()
        
        while remaining:
            # Find steps with satisfied dependencies
            ready = [
                step for step in remaining
                if all(dep in [s.id for s in sorted_steps] for dep in step.dependencies)
            ]
            
            if not ready:
                raise ValueError("Circular dependency detected in plan")
            
            sorted_steps.extend(ready)
            for step in ready:
                remaining.remove(step)
        
        return sorted_steps
    
    def _resolve_dependencies(self, args: Dict, results: Dict) -> Dict:
        """Replace dependency placeholders with actual results."""
        resolved = {}
        
        for key, value in args.items():
            if isinstance(value, str) and value.startswith("$step_"):
                # Reference to previous step result
                step_id = int(value.replace("$step_", ""))
                resolved[key] = results[step_id]["result"]
            else:
                resolved[key] = value
        
        return resolved
    
    def _format_tools(self) -> str:
        """Format tool descriptions."""
        return "\n".join([
            f"- {name}: {tool.description}"
            for name, tool in self.tools.items()
        ])

# Usage
agent = PlanExecuteAgent(llm, [search_database, calculate])

result = await agent.run(
    "Find the total revenue for Q2 2026 and calculate the 15% tax"
)

Plan-and-Execute with LangGraph

python
from langgraph.graph import StateGraph, END

class PlanExecuteState(TypedDict):
    """State for plan-and-execute agent."""
    task: str
    plan: List[Step]
    current_step: int
    step_results: Dict[int, any]
    status: str

def create_plan_execute_graph():
    """Plan-and-execute as LangGraph."""
    
    workflow = StateGraph(PlanExecuteState)
    
    def planning_node(state: PlanExecuteState):
        """Create execution plan."""
        planning_agent = PlanExecuteAgent(llm, tools)
        plan = await planning_agent.create_plan(state["task"])
        
        return {
            "plan": plan,
            "current_step": 0,
            "step_results": {}
        }
    
    def execution_node(state: PlanExecuteState):
        """Execute current step."""
        step = state["plan"][state["current_step"]]
        
        # Check dependencies satisfied
        for dep_id in step.dependencies:
            if dep_id not in state["step_results"]:
                return {
                    "status": "failed",
                    "error": f"Dependency {dep_id} not satisfied"
                }
        
        # Execute step
        result = await execute_step(step, state["step_results"])
        
        # Update results
        new_results = state["step_results"].copy()
        new_results[step.id] = result
        
        return {
            "step_results": new_results,
            "current_step": state["current_step"] + 1
        }
    
    def verification_node(state: PlanExecuteState):
        """Verify plan completion."""
        all_completed = len(state["step_results"]) == len(state["plan"])
        all_successful = all(
            r.get("status") == "success"
            for r in state["step_results"].values()
        )
        
        return {
            "status": "complete" if (all_completed and all_successful) else "failed"
        }
    
    def should_continue(state: PlanExecuteState) -> str:
        """Check if more steps remain."""
        if state["current_step"] < len(state["plan"]):
            return "execute_step"
        return "verify"
    
    workflow.add_node("planning", planning_node)
    workflow.add_node("execution", execution_node)
    workflow.add_node("verification", verification_node)
    
    workflow.add_edge("planning", "execution")
    
    workflow.add_conditional_edges(
        "execution",
        should_continue,
        {
            "execute_step": "execution",
            "verify": "verification"
        }
    )
    
    workflow.add_edge("verification", END)
    workflow.set_entry_point("planning")
    
    return workflow.compile()

Plan-and-Execute Advantages

Predictable execution:

  • Clear sequence of steps
  • Easier to estimate time and cost
  • Simpler monitoring and debugging

Efficient:

  • Fewer LLM calls
  • No repeated reasoning
  • Batch-friendly

Parallelizable:

  • Independent steps can run concurrently
  • Better resource utilization

Plan-and-Execute Challenges

Rigidity:

  • Cannot adapt to unexpected observations
  • Failed steps may cascade
  • Requires replanning on errors

Upfront planning cost:

  • Initial planning can be complex
  • May create suboptimal plans

Poor for exploration:

  • Not suitable for uncertain environments
  • Assumes complete information

Performance Comparison

Benchmarks from 1,000 production tasks:

MetricReActPlan-and-ExecuteDifference
Avg LLM Calls12.33.7+233% ReAct
Avg Cost$0.31$0.12+158% ReAct
Avg Latency18.4s8.2s+124% ReAct
Success Rate94.2%91.7%+2.5% ReAct
AdaptabilityHighLowReAct wins
PredictabilityLowHighPlan wins

Task complexity breakdown:

Task TypeReAct SuccessPlan SuccessBetter Pattern
Simple (1-3 steps)98%97%Tie
Medium (4-7 steps)95%93%ReAct
Complex (8+ steps)89%87%ReAct
Exploratory92%73%ReAct
Well-defined91%96%Plan
Interactive96%78%ReAct
Batch processing88%95%Plan

Key findings:

  • ReAct is more adaptable but costly
  • Plan-and-Execute is efficient but rigid
  • Task predictability is key decision factor

This data informs framework selection for production systems.

When to Use ReAct

Choose ReAct for:

1. Exploratory Tasks

Tasks requiring iterative discovery:

  • Research and information gathering
  • Debugging and root cause analysis
  • Open-ended problem solving

2. User Interaction

Conversational agents:

  • Customer support chatbots
  • Interactive assistants
  • Clarification-heavy workflows

3. Uncertain Environments

When conditions change:

  • Dynamic data sources
  • Real-time decision making
  • Adaptive workflows

4. Error Recovery

Graceful failure handling:

  • Retry with alternative approaches
  • Self-correction based on feedback

5. Small-Scale Operations

Cost less critical:

  • Prototypes and MVPs
  • Low-volume operations
  • Demo applications

Example ReAct task: "Help user troubleshoot their printer not working" - Requires back-and-forth questions and adaptive troubleshooting.

When to Use Plan-and-Execute

Choose Plan-and-Execute for:

1. Well-Defined Workflows

Clear step sequences:

  • Data processing pipelines
  • Report generation
  • Scheduled batch jobs

2. Cost-Sensitive Operations

Budget constraints:

  • High-volume automation
  • Repeatable tasks
  • Production at scale

3. Predictable Requirements

Known steps upfront:

  • Form processing
  • Standard procedures
  • Regulatory workflows

4. Parallel Execution

Independent steps:

  • Bulk data operations
  • Multi-source aggregation
  • Distributed processing

5. Long-Running Tasks

Extended execution:

  • Multi-hour workflows
  • Background jobs
  • Scheduled automation

Example Plan-and-Execute task: "Generate monthly sales report with charts and email to team" - Clear steps, no exploration needed.

Hybrid Approaches

Combine both patterns for best of both worlds:

Adaptive Planning

python
class AdaptivePlanExecuteAgent:
    """Plan-and-execute with replanning on errors."""
    
    async def execute_with_replanning(self, task: str):
        """Execute plan, replan on failures."""
        
        # Initial plan
        plan = await self.create_plan(task)
        
        completed_steps = []
        
        for step in plan:
            try:
                result = await self.execute_step(step)
                completed_steps.append((step, result))
            
            except Exception as e:
                logger.warning(
                    "Step failed, replanning",
                    extra={"step": step.id, "error": str(e)}
                )
                
                # Replan from current state
                remaining_task = self._formulate_remaining_task(
                    original_task=task,
                    completed_steps=completed_steps,
                    failed_step=step
                )
                
                new_plan = await self.create_plan(remaining_task)
                plan = new_plan  # Replace remaining plan
        
        return {"completed_steps": completed_steps}

Hierarchical Planning

python
class HierarchicalAgent:
    """Plan-and-execute at high level, ReAct for complex steps."""
    
    async def execute(self, task: str):
        """High-level planning, low-level ReAct."""
        
        # Create high-level plan
        plan = await self.create_high_level_plan(task)
        
        results = []
        
        for step in plan:
            if self._is_complex_step(step):
                # Use ReAct for complex steps
                logger.info(f"Using ReAct for complex step: {step.description}")
                result = await self.react_agent.run(step.description)
            else:
                # Direct execution for simple steps
                result = await self.execute_step(step)
            
            results.append(result)
        
        return {"results": results}
    
    def _is_complex_step(self, step: Step) -> bool:
        """Determine if step needs ReAct."""
        complexity_indicators = [
            "research",
            "investigate",
            "troubleshoot",
            "explore",
            "find best"
        ]
        
        return any(
            indicator in step.description.lower()
            for indicator in complexity_indicators
        )

Planning with Checkpoints

python
def create_checkpointed_plan_execute():
    """Plan-and-execute with mid-execution validation."""
    
    workflow = StateGraph(PlanExecuteState)
    
    # ... planning and execution nodes ...
    
    def validation_checkpoint(state: PlanExecuteState):
        """Validate progress mid-execution."""
        completed = state["step_results"]
        
        # Check if plan is still valid given results
        validation_prompt = f"""Given these completed steps:
        {json.dumps(completed, indent=2)}
        
        Is the remaining plan still appropriate:
        {json.dumps(state["plan"][state["current_step"]:], indent=2)}
        
        Respond: "yes" or "no" with explanation."""
        
        response = llm.invoke(validation_prompt)
        
        if "no" in response.content.lower():
            # Replan needed
            return {"status": "needs_replanning"}
        
        return {"status": "continue"}
    
    # Add validation after every 3 steps
    workflow.add_node("validation", validation_checkpoint)
    
    return workflow

These hybrid patterns appear in multi-agent systems.

Implementation with LangGraph

LangGraph provides both patterns out of the box:

ReAct with create_react_agent

python
from langgraph.prebuilt import create_react_agent

# ReAct agent in one line
react_agent = create_react_agent(
    llm=ChatOpenAI(model="gpt-4"),
    tools=[search_database, calculate],
    state_modifier="You are a helpful assistant."
)

# Use it
result = react_agent.invoke({
    "messages": [{"role": "user", "content": "Calculate tax on Q2 revenue"}]
})

Plan-and-Execute from Scratch

python
# Build custom plan-and-execute
def create_plan_execute_agent():
    workflow = StateGraph(PlanExecuteState)
    
    # Add planning, execution, verification nodes
    workflow.add_node("plan", planning_node)
    workflow.add_node("execute", execution_node)
    workflow.add_node("verify", verification_node)
    
    # Sequential flow
    workflow.add_edge("plan", "execute")
    workflow.add_conditional_edges(
        "execute",
        should_continue_executing,
        {"continue": "execute", "done": "verify"}
    )
    
    workflow.set_entry_point("plan")
    
    return workflow.compile(
        checkpointer=PostgresCheckpointer(DATABASE_URL)  # Stateful execution
    )

Learn more about stateful agents with checkpoints.

Cost Analysis

Cost comparison for 10,000 tasks per month:

PatternLLM CallsToken UsageMonthly CostCost/Task
ReAct123,00045M tokens$2,250$0.225
Plan-and-Execute37,00014M tokens$700$0.070
Hybrid (adaptive)68,00025M tokens$1,250$0.125

Cost factors:

  • Model size: GPT-4 vs GPT-3.5 vs Claude Sonnet
  • Task complexity: More steps = higher cost
  • Error rate: Failed attempts increase cost
  • Parallelization: Plan-and-Execute enables batching

Cost optimization:

  • Use Plan-and-Execute for high-volume tasks
  • Reserve ReAct for exploratory/interactive work
  • Implement caching for repeated reasoning
  • Use smaller models for planning when possible

Common Pitfalls

ReAct Pitfalls

1. Infinite loops:

python
# BAD: No loop detection
while not done:
    action = reason()
    result = execute(action)

# GOOD: Iteration limit and loop detection
for iteration in range(MAX_ITERATIONS):
    action = reason()
    
    if action in recent_actions[-3:]:
        logger.warning("Loop detected, breaking")
        break
    
    result = execute(action)

See preventing agent loops.

2. Prompt drift: Context grows unbounded, causing quality degradation.

Solution: Summarize old context periodically.

3. Expensive exploration: Agent explores unnecessarily when plan would suffice.

Solution: Use hybrid approach - plan when possible, ReAct when needed.

Plan-and-Execute Pitfalls

1. Brittle plans: Single failure cascades to entire workflow.

Solution: Implement replanning on errors.

2. Poor parallelization:

python
# BAD: Sequential when parallel possible
for step in plan:
    await execute(step)

# GOOD: Parallel execution of independent steps
independent_steps = find_independent_steps(plan)
await asyncio.gather(*[execute(step) for step in independent_steps])

3. Stale plans: Executing outdated plans based on old information.

Solution: Add validation checkpoints.

Testing Strategies

Testing ReAct Agents

python
import pytest

@pytest.mark.asyncio
async def test_react_reaches_answer():
    """Test ReAct completes task."""
    result = await react_agent("What is 2 + 2?")
    
    assert "4" in result["answer"]
    assert result["iterations"] < 10

@pytest.mark.asyncio
async def test_react_uses_tools():
    """Test ReAct calls appropriate tools."""
    with patch('tools.calculate') as mock_calc:
        mock_calc.return_value = 4
        
        result = await react_agent("Calculate 2 + 2")
        
        assert mock_calc.called
        assert result["answer"] == "4"

@pytest.mark.asyncio
async def test_react_recovers_from_errors():
    """Test ReAct handles tool failures."""
    with patch('tools.search_database') as mock_search:
        # First call fails, second succeeds
        mock_search.side_effect = [
            Exception("Connection error"),
            {"results": ["data"]}
        ]
        
        result = await react_agent("Search for user data")
        
        assert result["iterations"] > 1  # Retried
        assert "data" in str(result)

Testing Plan-and-Execute Agents

python
@pytest.mark.asyncio
async def test_plan_execute_creates_valid_plan():
    """Test planning creates executable plan."""
    agent = PlanExecuteAgent(llm, tools)
    
    plan = await agent.create_plan("Generate report")
    
    # Verify plan structure
    assert len(plan) > 0
    assert all(hasattr(step, 'id') for step in plan)
    assert all(hasattr(step, 'tool') for step in plan)
    
    # Verify dependencies are valid
    step_ids = {step.id for step in plan}
    for step in plan:
        assert all(dep in step_ids for dep in step.dependencies)

@pytest.mark.asyncio
async def test_plan_execute_handles_failures():
    """Test failure handling in execution."""
    agent = PlanExecuteAgent(llm, tools)
    
    with patch.object(agent.tools['search_database'], 'ainvoke') as mock:
        mock.side_effect = Exception("Database error")
        
        result = await agent.run("Search and analyze data")
        
        assert result["status"] == "failed"
        assert "error" in str(result)

Learn comprehensive agent testing strategies.

Operating ReAct vs Plan-and-Execute as a System

The implementation is only one part of ReAct vs Plan-and-Execute. A production design also needs an explicit contract for inputs, outputs, ownership, and failure behavior. Write that contract before selecting a library. It should identify which component validates input, where state lives, what may be retried, and which result is authoritative when two components disagree. This prevents a convenient prototype boundary from silently becoming the long-term architecture.

Start with a representative baseline. Capture request shape, traffic distribution, dependency latency, error classes, and the quality signal users actually care about. Averages hide the cases that cause incidents, so keep percentiles and segment measurements by workload type. Record the configuration and dataset version beside every result. Without that context, a faster or more accurate run cannot be reproduced and should not be used to approve a rollout.

Define the failure model

List failures by where they originate: invalid input, capacity exhaustion, dependency timeout, partial state change, malformed output, and semantically wrong output. Each class needs a different response. Validation errors should fail immediately. Transient dependency failures may be retried with a budget and jitter. An operation that may have committed must use an idempotency key or reconciliation step before retrying. A syntactically valid but incorrect result belongs in evaluation and review, not a blind retry loop.

Set a deadline for the complete operation and derive smaller budgets for each dependency. Local timeouts that add up to more than the caller's deadline merely create abandoned work. Propagate cancellation where the protocol supports it. Bound every queue, retry loop, context buffer, and concurrency pool; an unbounded safety mechanism becomes a second outage during overload.

Design a degraded mode before it is needed. Depending on the workload, that can mean returning a cached answer, selecting a simpler path, placing work in a durable queue, or asking for human review. The degraded response must be visible in telemetry and, where it changes meaning, visible to the caller. Silent fallback makes quality regressions almost impossible to diagnose.

Measure the decision, not just the component

Use three layers of signals. System metrics cover latency, throughput, saturation, and errors. Correctness metrics measure whether the result satisfies its contract. Business or user metrics show whether the system solved the intended problem. Improving only one layer can move the others backward, so release criteria should name acceptable movement for all three.

Attach a reason code to every route, rejection, fallback, and retry. Include version identifiers for configuration, code, model, schema, and data when relevant. Logs should let an engineer reconstruct a decision without storing secrets or raw personal data. Traces should cross process boundaries, while metrics should remain low-cardinality enough to operate reliably.

Alert on symptoms that require action, not every internal anomaly. A useful alert names the affected service objective, links to a runbook, and distinguishes a customer-visible incident from exhausted headroom. Dashboards serve a different purpose: they support diagnosis and capacity planning. Treating a dashboard as an alerting strategy leaves failures undiscovered until someone happens to look.

Roll out with reversible steps

Ship ReAct vs Plan-and-Execute behind a versioned interface and a kill switch. Begin with offline replay using production-shaped, privacy-safe samples. Then use shadow execution when duplicate work has acceptable cost and side effects can be suppressed. A small canary should exercise the real dependency graph before traffic expands. Compare the canary with the baseline by cohort rather than mixing both populations into one aggregate.

Promotion gates should be written before the rollout. Include a minimum sample size or observation window, maximum regression in tail latency and error rate, and a correctness threshold. Roll back automatically when a hard safety boundary is crossed; use manual review for ambiguous quality movement. Preserve enough evidence from both paths to explain why the gate passed or failed.

Configuration deserves the same discipline as code. Review changes, validate them before activation, keep an immutable history, and make rollback a single operation. If a deployment changes code and configuration together, record both versions. Otherwise an incident responder may roll back the binary while leaving the triggering configuration active.

Capacity and cost controls

Model capacity in units the bottleneck understands: concurrent connections, tokens, queue jobs, database transactions, GPU memory, or bytes in flight. Convert the expected traffic distribution into those units and include burst behavior. Then load-test the first constrained dependency, not merely the public endpoint. A system that accepts more work than it can finish within its deadline is overloaded even if CPU utilization looks comfortable.

Cost is also a reliability limit. Add per-request attribution, tenant or workflow budgets, and a global circuit breaker for unexpectedly expensive paths. Review unit economics at the same granularity as performance; a cheap median can conceal a small class of requests responsible for most spend. Optimize only after measuring, because reducing context, replicas, validation, or redundancy can trade visible cost for less visible risk.

Production readiness review

Before launch, ask an engineer who did not build the feature to follow the runbook through one simulated failure. Verify backups or checkpoints by restoring them, not by checking that a job reported success. Exercise credential rotation, dependency unavailability, bad configuration, and rollback. Assign an owner for each alarm and a date for reviewing thresholds after real traffic arrives.

The final architecture document should be short enough to remain current. Keep the decision, rejected alternatives, invariants, dependency contracts, dashboards, and rollback procedure. Link detailed experiments rather than pasting them into the document. Teams that need help turning this review into an operable service can use our ReAct vs Plan-and-Execute engineering support.

Frequently Asked Questions

Which pattern is better for production?

It depends on your use case. For well-defined, repeatable workflows, Plan-and-Execute is better - it's cheaper, faster, and more predictable. For exploratory tasks, user interaction, or uncertain environments, ReAct is better - it's more adaptive and handles unexpected situations gracefully.

Most production AI agent systems use hybrid approaches - Plan-and-Execute for standard workflows, ReAct for complex or interactive steps.

Can I switch patterns mid-execution?

Yes, with hierarchical approaches:

  1. Plan high-level steps (Plan-and-Execute)
  2. Identify complex steps requiring exploration
  3. Use ReAct for those steps only
  4. Continue with plan for remaining steps

This gives you efficiency where possible and adaptability where needed.

How do I prevent ReAct loops?

Implement loop detection:

python
recent_actions = deque(maxlen=5)

for iteration in range(MAX_ITERATIONS):
    action = get_next_action()
    
    # Check for repeated actions
    if action in recent_actions:
        logger.warning("Repeated action detected")
        break
    
    recent_actions.append(action)
    execute(action)

See our guide on preventing agent loops.

How do I make Plan-and-Execute adaptive?

Implement replanning:

  1. Execute steps sequentially
  2. Validate progress after each step
  3. Replan if needed based on results
  4. Continue with new plan

This adds some ReAct-like adaptability while maintaining structure.

Which pattern costs less?

Plan-and-Execute costs ~70% less than ReAct in our benchmarks ($0.07 vs $0.23 per task). The savings come from:

  • Fewer LLM calls (3.7 vs 12.3 average)
  • Less token usage (no repeated reasoning)
  • More efficient execution

For cost-sensitive production systems, prefer Plan-and-Execute when possible.

Can I parallelize ReAct?

ReAct is inherently sequential - each reasoning step depends on previous observations. However, you can:

  • Parallelize tool calls within a single iteration
  • Run multiple ReAct agents concurrently for independent tasks
  • Use Plan-and-Execute for the outer structure with ReAct for complex steps

How do I test reasoning patterns?

Test at multiple levels:

  1. Unit tests: Individual nodes (planning, execution, reasoning)
  2. Integration tests: Full workflows end-to-end
  3. Regression tests: Capture successful reasoning traces
  4. Property tests: Verify loop detection, timeout handling

Mock LLM responses for deterministic testing.

Which frameworks support both patterns?

LangGraph supports both:

  • create_react_agent() for ReAct
  • Custom StateGraph for Plan-and-Execute
  • Mix and match in same application

CrewAI is primarily Plan-and-Execute with task sequences.

AutoGen is primarily ReAct with conversational loops.

See framework comparison.

How do I choose for a new project?

Decision tree:

  1. Is the workflow well-defined? → Plan-and-Execute
  2. Requires user interaction? → ReAct
  3. High volume? → Plan-and-Execute (cost)
  4. Uncertain environment? → ReAct
  5. Need predictable timing? → Plan-and-Execute
  6. Exploratory task? → ReAct

When unsure, prototype with ReAct (faster development), then optimize to Plan-and-Execute if the workflow stabilizes.

Can I mix both patterns in one agent?

Yes, hybrid agents are common:

python
def hybrid_agent(task):
    # Analyze task complexity
    if is_well_defined(task):
        return plan_execute_agent(task)
    else:
        return react_agent(task)

Or use hierarchical: Plan-and-Execute for structure, ReAct for complex steps.

Conclusion

ReAct and Plan-and-Execute serve different purposes in production AI agents. ReAct excels at adaptive, exploratory, and interactive tasks but costs more. Plan-and-Execute is efficient, predictable, and cost-effective for well-defined workflows but lacks flexibility.

Key decision factors:

  • Task predictability: Known steps → Plan-and-Execute; Uncertain → ReAct
  • Cost sensitivity: High volume → Plan-and-Execute; Low volume → ReAct
  • Interactivity: User interaction → ReAct; Batch processing → Plan-and-Execute
  • Adaptability needs: Dynamic environment → ReAct; Stable → Plan-and-Execute

Best practice: Use hybrid approaches in production systems - Plan-and-Execute as the default for efficiency, ReAct for complex steps requiring exploration. This balances cost, reliability, and adaptability.

Start with the simplest pattern that solves your problem, then optimize based on production metrics.

Ready to build AI agents with the right reasoning pattern? Contact our team or explore our agent architecture case studies.


Free consultation

Book a free consultation call on agent reasoning patterns

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading