Module 5: Multi-Step Reasoning and Planning

2. ReAct Deep Dive

Description

You've used ReAct three times already. In Module 1 you implemented the loop by hand with the OpenAI SDK — prompt, parse tool_calls, execute, append, repeat — and then you replicated it with create_react_agent. In Module 4 you turned that same loop into a StateGraph with reason and tools nodes connected by conditional edges. You know what ReAct does. What you don't know is how it does it under the hood.

When you call create_react_agent(model, tools), the framework injects a system prompt you never see. That invisible prompt tells the model how to interleave reasoning with action, when to use tools and when to answer directly. It sets the rules of the game. If you don't know that prompt, you're operating blind — you can't diagnose why the agent picked the wrong tool, why it generated a redundant Thought, or why it decided to finish early. This capsule opens that black box.

What you'll do here is different from everything before: you're going to read ReAct's internal prompt, understand the real stop conditions (beyond max_iterations), learn to debug the reasoning chain step by step, and customize the agent's behavior with custom system prompts. By the end, the difference between "my agent works but I don't know why" and "I know exactly what it decided at each step and I can tune it" will be the difference between you before and after this capsule.


Opening ReAct's Black Box

What happens when you call create_react_agent

When you write create_react_agent(model, tools), LangGraph does five invisible things:

  1. Builds a StateGraph with two nodes: agent (reasoning) and tools (execution)
  2. Injects a system prompt that instructs the model on how to reason
  3. Binds the tools to the model with model.bind_tools(tools)
  4. Configures the routing — a conditional edge that sends to tools if there are tool_calls, or to END if not
  5. Defines stop conditions — a default recursion_limit to prevent infinite loops

You see agent = create_react_agent(model, tools). What exists internally is something like:

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage, SystemMessage
from typing import TypedDict, Annotated

class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]

def agent_node(state: AgentState) -> dict:
    system_prompt = SystemMessage(content=REACT_SYSTEM_PROMPT)
    messages = [system_prompt] + state["messages"]
    response = model_with_tools.invoke(messages)
    return {"messages": [response]}

def tool_node(state: AgentState) -> dict:
    # Execute every tool_call from the last message
    ...

def should_continue(state: AgentState) -> str:
    last = state["messages"][-1]
    if hasattr(last, "tool_calls") and last.tool_calls:
        return "tools"
    return "end"

graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
graph.add_edge("tools", "agent")

That's the skeleton. But the detail that makes the difference is in REACT_SYSTEM_PROMPT — the prompt you never see and which controls the agent's entire behavior.

The key decision: think vs act

On every iteration of the loop, the model makes a binary decision:

DecisionSignalWhat it means
ActReturns tool_calls in the AIMessageThe model needs external information
AnswerReturns content with no tool_callsThe model has enough information to answer

There's no third option. The model can't "think out loud" separately (as in the original ReAct paper) — in LangGraph's implementation, the "Thought" is embedded in the decision of which tool to call and with what arguments. When the model chooses search("best testing practices in Python"), the implicit Thought is "I need to look up information about testing in Python." When it returns with no tool_calls, the implicit Thought is "I already have what I need."

This differs from the original ReAct paper (Yao et al., 2022), where Thought, Action, and Observation were explicit textual steps:

Thought: I need to look up the population of France
Action: search("population of France")
Observation: France has a population of approximately 67.75 million
Thought: I have the information. I'm going to answer.
Answer: The population of France is approximately 67.75 million.

In LangGraph's implementation, Thoughts aren't visible text — they're implicit model decisions expressed through the presence or absence of tool_calls. This has direct implications for debugging: you can't read the agent's "thinking" directly; you have to infer it from its actions.

The full cycle: a real step-by-step

Let's run an agent and trace each step:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def web_search(query: str) -> str:
    """Search the web for up-to-date information."""
    return f"Results for '{query}': Python 3.13 was released in October 2024 with GIL improvements."

@tool
def calculator(expression: str) -> str:
    """Evaluate a mathematical expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [web_search, calculator])

result = agent.invoke(
    {"messages": [("user", "What's the latest version of Python and how much is 2**10?")]},
    {"recursion_limit": 20}
)

for msg in result["messages"]:
    print(f"[{msg.__class__.__name__}] {msg.content[:100] if msg.content else 'tool_calls: ' + str(msg.tool_calls)}")

The trace shows something like:

[HumanMessage]  What's the latest version of Python and how much is 2**10?
[AIMessage]     tool_calls: [web_search("latest Python version"), calculator("2**10")]
[ToolMessage]   Results for 'latest Python version': Python 3.13 was...
[ToolMessage]   1024
[AIMessage]     The latest version of Python is 3.13, and 2^10 = 1024.

Notice: the model made parallel tool calls — it called web_search and calculator at the same time. It didn't need to wait for one result to call the other. That's a real optimization that cuts latency in half in this case.


ReAct's Internal Prompt

The system prompt you never see

When you use create_react_agent with the prompt parameter, that text is injected as a system message. But even without a custom prompt, the framework structures the context in a specific way. ReAct behavior doesn't emerge magically from the model — it emerges from how the conversation is presented.

The effective structure the model receives on each invocation is:

[System]     Your system prompt (if you passed one) or default instructions
[Human]      The user's original question
[AI]         Tool calls from iteration 1 (if any)
[Tool]       Result of tool 1
[Tool]       Result of tool 2
[AI]         Tool calls from iteration 2 (if any)
[Tool]       Result of tool 3
...

The model sees the entire history of the conversation on every iteration. This is crucial: when the agent decides whether it needs more information or can answer, it's looking at all the previous tool results, not just the last one. The agent's "working memory" is literally the list of messages.

How the system prompt influences the reasoning

You can pass an explicit system prompt to create_react_agent:

agent = create_react_agent(
    model,
    tools=[web_search, calculator],
    prompt="You are a research assistant. Always verify information with a search before answering. If you're not sure, search more."
)

That prompt changes the agent's reasoning behavior:

Without a custom promptWith a research prompt
Answers directly if it "knows" the answerAlways searches before answering
Uses tools only when necessaryUses tools proactively
Finishes fast (1-2 iterations)More iterations (2-4), more thorough
Lower cost ($0.01-0.02)Higher cost ($0.03-0.06), better result

Anatomy of an effective ReAct prompt

A prompt for a ReAct agent has three components:

REACT_RESEARCH_PROMPT = """You are a specialized research agent.

## Role and Behavior
- Research thoroughly before answering
- Use the available tools to get up-to-date data
- Don't make up information — if you can't find it, say so explicitly

## Research Strategy
- For factual questions: search first, answer after
- For calculations: use the calculator, don't compute mentally
- For compound questions: split into sub-questions and solve each one

## Response Format
- Answer in English
- Cite sources whenever possible
- If the information is contradictory, mention the different perspectives
"""

agent = create_react_agent(model, tools, prompt=REACT_RESEARCH_PROMPT)

Each section plays a role:

SectionWhat it controlsEffect on the agent
Role and BehaviorIdentity and rulesDefines when to search vs answer directly
Research StrategyReasoning logicGuides the sequence of tool calls
Response FormatFinal outputStructures the answer to the user

Experiment with the prompt to see the effect

Create two agents with opposite prompts and compare their behavior on the same question:

agent_minimo = create_react_agent(
    model,
    tools=[web_search, calculator],
    prompt="Answer as briefly as possible. Use tools only if absolutely necessary."
)

agent_thorough = create_react_agent(
    model,
    tools=[web_search, calculator],
    prompt="Research exhaustively. Run multiple searches with different queries. Verify the data by cross-checking sources."
)

query = {"messages": [("user", "How many people live in Ciudad de México?")]}

result_min = agent_minimo.invoke(query, {"recursion_limit": 20})
result_thor = agent_thorough.invoke(query, {"recursion_limit": 20})

print(f"Minimal agent: {len(result_min['messages'])} messages")
print(f"Thorough agent: {len(result_thor['messages'])} messages")

Typically you'll see: the minimal agent answers with 3 messages (user → AI with 1 tool call → answer), while the thorough one generates 7-9 messages (multiple searches, cross-verification). Same model, same tools — the prompt changed the reasoning.


Advanced Stop Conditions

The stop conditions you already know

In M1 and M4 you worked with two stop conditions:

  1. No tool_calls: the model returns an AIMessage with content but no tool_calls → the agent is done
  2. recursion_limit: the graph reaches the maximum number of steps → it stops by force

These are enough for prototypes. In production, you need more granularity.

recursion_limit: the real mechanics

recursion_limit counts graph steps, not agent iterations. If your graph has an agent → tools → agent cycle, each iteration consumes 2 steps. So recursion_limit=10 allows ~5 full agent iterations.

agent = create_react_agent(model, tools)

try:
    result = agent.invoke(
        {"messages": [("user", "Research everything about quantum computing")]},
        {"recursion_limit": 6}
    )
except Exception as e:
    print(f"Limit reached: {e}")

If the agent needs 4 iterations (8 steps) but the limit is 6, it gets interrupted halfway through the third cycle. The agent produces no final answer — it just stops. That's a problem in production because the user gets nothing useful.

Stop condition with a fallback message

To prevent the agent from dying silently when it hits the limit, implement a wrapper that catches the interruption and generates a partial answer:

from langchain_core.messages import HumanMessage

def invoke_with_fallback(agent, query: str, max_iterations: int = 5):
    """Invoke the agent with a fallback if it hits the limit."""
    recursion_limit = max_iterations * 2 + 1
    
    try:
        result = agent.invoke(
            {"messages": [("user", query)]},
            {"recursion_limit": recursion_limit}
        )
        return result
    except Exception:
        result = agent.invoke(
            {"messages": [
                ("user", query),
                ("assistant", "I researched partially but reached my iteration limit."),
                ("user", "With the information you have so far, give the best possible answer.")
            ]},
            {"recursion_limit": 3}
        )
        return result

result = invoke_with_fallback(agent, "Explain the history of machine learning", max_iterations=3)
print(result["messages"][-1].content)

Stop condition based on tokens/cost

In production, cost matters. You can implement a stop condition that monitors consumed tokens:

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages

class BudgetState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    total_tokens: int
    max_tokens_budget: int

def agent_node(state: BudgetState) -> dict:
    response = model_with_tools.invoke(state["messages"])
    tokens_used = response.usage_metadata.get("total_tokens", 0) if hasattr(response, "usage_metadata") and response.usage_metadata else 0
    return {
        "messages": [response],
        "total_tokens": state.get("total_tokens", 0) + tokens_used
    }

def should_continue(state: BudgetState) -> str:
    if state.get("total_tokens", 0) >= state.get("max_tokens_budget", 10000):
        return "budget_exceeded"
    last = state["messages"][-1]
    if hasattr(last, "tool_calls") and last.tool_calls:
        return "tools"
    return "end"

This lets you say "this agent has a budget of 5,000 tokens" and have it stop before exceeding it, instead of discovering the cost afterward.

Stop condition based on quality

The most sophisticated one: the agent evaluates whether its answer is good enough before finishing. This is a preview of reflection (capsule 05), but the stop condition mechanics are useful on their own:

def quality_check_node(state: BudgetState) -> dict:
    """Evaluate whether the current answer is good enough."""
    last_response = state["messages"][-1].content
    
    check_prompt = f"""Rate this answer on a scale of 1-10:
    
Answer: {last_response}

Criteria:
- Does it answer the question directly? (1-3 points)
- Does it include specific data? (1-3 points)  
- Is it coherent and complete? (1-4 points)

Reply with the number ONLY."""
    
    score_response = model.invoke([("user", check_prompt)])
    try:
        score = int(score_response.content.strip())
    except ValueError:
        score = 5
    
    from langchain_core.messages import SystemMessage
    return {
        "messages": [SystemMessage(content=f"[QUALITY_SCORE: {score}/10]")]
    }

def route_after_quality(state: BudgetState) -> str:
    for msg in reversed(state["messages"]):
        if hasattr(msg, "content") and "[QUALITY_SCORE:" in msg.content:
            score = int(msg.content.split(":")[1].split("/")[0].strip())
            if score >= 7:
                return "deliver"
            return "retry"
    return "deliver"

The trade-off: every quality check is an additional LLM call (~$0.005-0.01). In a 5-iteration investigation, adding quality checks doubles the cost. But if your agent delivers bad answers 30% of the time, the quality check may be worth it.


Debugging the Reasoning

The problem: invisible reasoning

When a ReAct agent fails, the symptom is obvious — wrong answer, infinite loop, wrong tool. The cause is invisible — at which reasoning step did it go wrong? Did it pick the wrong tool? Did it misread a result? Did it finish too soon?

Without debugging the reasoning, you're guessing. With debugging, you're diagnosing.

Level 1: Manual message inspection

The most basic and the most useful. After running the agent, examine each message:

result = agent.invoke({"messages": [("user", "What is Mexico's GDP and how much is that in euros?")]})

for i, msg in enumerate(result["messages"]):
    msg_type = msg.__class__.__name__
    
    if msg_type == "HumanMessage":
        print(f"\n--- Step {i}: INPUT ---")
        print(f"  {msg.content}")
    
    elif msg_type == "AIMessage":
        if msg.tool_calls:
            print(f"\n--- Step {i}: DECISION → ACT ---")
            for tc in msg.tool_calls:
                print(f"  Tool: {tc['name']}({tc['args']})")
        else:
            print(f"\n--- Step {i}: DECISION → ANSWER ---")
            print(f"  {msg.content[:200]}")
    
    elif msg_type == "ToolMessage":
        print(f"\n--- Step {i}: OBSERVATION ---")
        print(f"  {msg.content[:200]}")

Example output:

--- Step 0: INPUT ---
  What is Mexico's GDP and how much is that in euros?

--- Step 1: DECISION → ACT ---
  Tool: web_search({'query': 'Mexico GDP 2024'})
  Tool: web_search({'query': 'USD EUR exchange rate today'})

--- Step 2: OBSERVATION ---
  Mexico's GDP: approximately 1.3 trillion USD...

--- Step 3: OBSERVATION ---
  1 USD = 0.92 EUR...

--- Step 4: DECISION → ACT ---
  Tool: calculator({'expression': '1.3e12 * 0.92'})

--- Step 5: OBSERVATION ---
  1196000000000.0

--- Step 6: DECISION → ANSWER ---
  Mexico's GDP is approximately 1.3 trillion dollars...

With this trace you can see the full reasoning chain: the model split the question into two parallel searches, got the data, then computed the conversion, and finally synthesized. If something is wrong, you know exactly where to look.

Level 2: Step-by-step streaming

For slow agents or ones with many iterations, you don't want to wait until the end. Use streaming:

for step in agent.stream(
    {"messages": [("user", "Research the trends in AI agents for 2026")]},
    {"recursion_limit": 20},
    stream_mode="updates"
):
    for node_name, update in step.items():
        print(f"\n{'='*50}")
        print(f"Node: {node_name}")
        if "messages" in update:
            for msg in update["messages"]:
                if hasattr(msg, "tool_calls") and msg.tool_calls:
                    for tc in msg.tool_calls:
                        print(f"  → Calling: {tc['name']}({tc['args']})")
                elif msg.content:
                    print(f"  → {msg.content[:150]}")

Streaming gives you real-time visibility. Instead of waiting 30 seconds and getting the final result, you see each decision as it happens. This is indispensable when the agent enters a loop — you catch it immediately instead of waiting for the timeout.

Level 3: LangSmith traces

LangSmith is LangChain's official observability tool. It gives you the full trace with timings, tokens, and costs:

import os
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_API_KEY"] = "ls__..."
os.environ["LANGSMITH_PROJECT"] = "react-debugging"

result = agent.invoke({"messages": [("user", "What is MCP and what is it used for?")]})

In the LangSmith dashboard you see:

DataWhat it shows you
Trace timelineVisual sequence of every LLM call and tool execution
Latency per stepHow many ms each node took (reason: 1200ms, tool: 300ms)
Tokens per stepInput/output tokens of each LLM call
Accumulated costTotal $ for the full execution
Exact input/outputThe exact prompt and response of every call to the model

Common failure patterns and how to diagnose them

When you examine the reasoning trace, you look for these patterns:

Pattern 1: Tool loop — the agent calls the same tool with the same arguments over and over.

Step 1: web_search("AI trends 2026")
Step 2: web_search("AI trends 2026")    ← Repetition
Step 3: web_search("AI trends 2026")    ← Loop detected

Cause: the tool result doesn't provide enough information and the model doesn't know what else to do. Fix: improve the system prompt so the agent varies its queries, or add a repetition detector.

Pattern 2: Premature termination — the agent answers without using tools when it should have used them.

Step 0: "What's the current price of Bitcoin?"
Step 1: "Bitcoin's price is approximately $45,000"    ← Answered without searching

Cause: the model "believes" it knows the answer (training data). Fix: a system prompt that forces a search for data that changes: "Always search for prices, statistics, and recent data — never use your prior knowledge for data that changes over time."

Pattern 3: Wrong tool — the agent picks a tool that doesn't apply.

Step 0: "How much is 15% of 340?"
Step 1: web_search("15% of 340")    ← Should use calculator

Cause: the tools' docstrings aren't clear enough to tell them apart. Fix: improve the tool descriptions.

Pattern 4: Over-investigation — the agent runs 10 searches when 2 would have been enough.

Step 1: web_search("Python history")
Step 3: web_search("Python creator")
Step 5: web_search("Python first release")
Step 7: web_search("Python versions timeline")
Step 9: web_search("Python language evolution")    ← Too much

Cause: the system prompt emphasizes "research thoroughly" without defining when to stop. Fix: add sufficiency criteria: "Run 3 searches at most. If after 3 searches you don't have the answer, synthesize with what you have."


ReAct with Custom System Prompts

Why customize the prompt

The default behavior of create_react_agent is generic: the agent uses tools when it thinks it's convenient and answers when it thinks it has enough information. For specific domains, that "thinks" needs explicit guidance.

A technical support agent needs a different prompt from a financial research agent. Not because of the tools — both can have web_search and database_query — but because of the reasoning strategy: when to search, when to ask the user, when to escalate, what level of certainty is acceptable before answering.

Prompt for a research agent

RESEARCH_PROMPT = """You are a rigorous research agent.

## Research Rules
1. NEVER answer with information you haven't verified with the tools
2. For factual questions, run at least 2 searches with different queries
3. If two sources contradict each other, mention it explicitly
4. Cite the source of every important data point

## Search Strategy
- First search: a direct query about the question
- Second search: a reformulated query or one about a specific aspect
- If the results are insufficient: try a third perspective
- Maximum 4 searches per question

## Response Format
- Direct answer first (1-2 sentences)
- Details and context after
- Sources at the end
"""

research_agent = create_react_agent(model, tools, prompt=RESEARCH_PROMPT)

Prompt for a code agent

CODE_PROMPT = """You are an agent specialized in programming.

## Code Rules
1. Check the current version of any library before giving code
2. Always look up the official documentation — don't assume the API from memory
3. If a function you need doesn't exist, say so instead of inventing it

## Response Process
1. Search the relevant documentation
2. Check the version and compatibility
3. Write the code based on the documentation you found
4. If there are recent breaking changes, mention them

## Anti-patterns
- DON'T generate code without verifying that the API exists
- DON'T assume versions — verify them
- DON'T use imports you haven't confirmed
"""

code_agent = create_react_agent(model, tools, prompt=CODE_PROMPT)

Prompt for a conservative agent (production)

In production, you often want an agent that does less, not more:

PRODUCTION_PROMPT = """You are a production assistant. Prioritize accuracy over completeness.

## Strict Rules
1. If you're not 100% sure, search before answering
2. Maximum 3 tool iterations — if you don't have an answer in 3, say you couldn't find the information
3. NEVER invent data, URLs, numbers, or statistics
4. If the question is out of scope, say so immediately without searching

## Efficiency
- One well-formulated search > three vague searches
- Prefer short, precise answers over long, speculative ones
"""

production_agent = create_react_agent(model, tools, prompt=PRODUCTION_PROMPT)

Measuring the impact of the prompt

The prompt isn't cosmetic — it changes real metrics. Compare empirically:

import time

def benchmark_agent(agent, query: str, label: str):
    start = time.time()
    result = agent.invoke(
        {"messages": [("user", query)]},
        {"recursion_limit": 20}
    )
    elapsed = time.time() - start
    num_messages = len(result["messages"])
    num_tool_calls = sum(
        len(m.tool_calls) for m in result["messages"]
        if hasattr(m, "tool_calls") and m.tool_calls
    )
    response = result["messages"][-1].content
    
    print(f"\n[{label}]")
    print(f"  Messages: {num_messages}")
    print(f"  Tool calls: {num_tool_calls}")
    print(f"  Time: {elapsed:.1f}s")
    print(f"  Answer: {response[:150]}...")
    return {"messages": num_messages, "tools": num_tool_calls, "time": elapsed}

query = "What are the 3 most used ML libraries in Python in 2026?"

benchmark_agent(research_agent, query, "Research")
benchmark_agent(production_agent, query, "Production")

Typical data:

MetricResearch AgentProduction Agent
Tool calls3-41-2
Time8-12s3-5s
Messages7-93-5
Approx cost$0.04-0.06$0.01-0.03
QualityHigh — verifies, cross-checks sourcesGood — direct, no verification

Neither is "better." The research agent is better for reports where accuracy matters. The production agent is better for fast answers where cost and latency dominate.


ReAct's Limitations

When ReAct isn't enough

ReAct is powerful for tasks that require interleaving reasoning with action, but it has structural limitations you need to know in order to pick the right pattern.

Limitation 1: No advance planning

ReAct is reactive — it decides what to do at each step based on what it has so far. It doesn't generate a complete plan before executing. That's a problem for tasks where order matters:

Task: "Compare the GDP per capita of the 5 fastest-growing G7 countries,
       adjusted for purchasing power parity, over the last 3 years."

ReAct (reactive):
  Step 1: Searches "G7 GDP per capita"           → gets partial data
  Step 2: Searches "G7 fastest growth"           → gets another partial list
  Step 3: Searches "purchasing power parity"     → gets definitions, not data
  Step 4: ???                                    → confused, fragmented results

Plan-and-Execute (deliberative):
  Plan: 1) Identify the 7 G7 countries
        2) Look up the PPP GDP of each one for 2023, 2024, 2025
        3) Compute the percentage growth
        4) Rank and select the top 5
        5) Format the comparison
  Execution: follows the plan step by step

For multi-step tasks with dependencies, Plan-and-Execute (capsule 03) beats ReAct.

Limitation 2: No self-evaluation

ReAct has no internal mechanism for evaluating the quality of its answer. When the agent decides to finish (no more tool_calls), it delivers what it has — without checking whether it answers the question, whether the data is coherent, or whether there are gaps.

Task: "Research the pros and cons of microservices vs monolith"

ReAct: searches, gets 2 results, synthesizes → delivers
       (the "cons of microservices" perspective may be missing)

ReAct + Reflection: searches, gets 2 results, synthesizes → evaluates
       → "The cons perspective is missing" → searches more → re-synthesizes → delivers

Reflection (capsule 05) adds that self-evaluation capability.

Limitation 3: Inefficiency on repetitive tasks

If the agent needs to do the same operation 10 times with different data, ReAct does it sequentially — search one, process, search another, process. It has no ability to "batch" or "parallelize" at the strategy level.

Task: "Look up the current price of AAPL, GOOGL, MSFT, AMZN, META"

ReAct: 5 sequential searches (if the model doesn't do parallel calls)
Task Decomposition: identifies the 5 sub-tasks, runs them in parallel

Task Decomposition (capsule 04) solves this with parallel execution of sub-tasks.

Limitation 4: Context that grows without limit

Every ReAct iteration adds messages to the history. In an agent that runs 10 iterations, the context can grow to thousands of tokens. This has two effects:

  1. Cost: every LLM invocation processes the entire history (input tokens)
  2. Lost in the middle: models struggle to pay attention to information in the middle of long contexts

There's no solution within pure ReAct. Memory Systems (M6) address this with compaction and summarization strategies.

When to use each pattern

ScenarioRecommended pattern
Simple question needing 1-2 toolsReAct
Complex task with 5+ dependent stepsPlan-and-Execute
Tasks where quality is criticalReAct + Reflection
Many independent sub-tasksTask Decomposition
All of the above combinedCustom StateGraph (M4)

ReAct is your default. Scale up to other patterns when ReAct's limitations hurt the quality of the result.


Comparison: Plain ReAct vs Configured ReAct

AspectPlain ReAct (create_react_agent default)Configured ReAct (custom prompt + stop conditions)
Setup3 lines of code20-40 lines
System promptNone or genericDomain-specific
Stop conditionsOnly recursion_limitBudget, quality, timeout, custom
DebuggingManual post-execution inspectionStreaming + structured logging
Search strategyThe model decides freelyGuided by the prompt (min/max searches)
Error handlingFails silentlyFallback, retry, clear error messages
Typical cost$0.01-0.03 per query$0.02-0.08 (more control = more LLM calls)
Typical latency2-5s5-15s
Answer qualityVariable — depends on the modelConsistent — guided by prompt and checks
Ideal forPrototypes, demos, simple tasksProduction, complex tasks, specific domains
ObservabilityBasicTraces, metrics, LangSmith integration
CustomizationMinimalTotal — every aspect of the reasoning is tunable

The rule: start with plain ReAct. When you identify a specific failure pattern (premature termination, over-investigation, wrong tool), add the configuration that solves it. Don't configure prematurely.


Connection with the Project

Module 5 — Research Agent with Planning and Reflection

In capsule 08 you'll add planning and reflection to the Research Agent. This capsule's ReAct deep dive is the direct foundation:

  • Custom system prompt: Your Research Agent will have a research prompt that defines when to search, when to analyze, and when to synthesize — exactly the pattern you saw in the custom prompts section
  • Stop conditions: You'll implement budget-based stops so the agent doesn't exceed a reasonable number of searches
  • Debugging: You'll use streaming and structured logging to verify that planning and reflection work correctly

The next capsules in this module

What you learned here connects directly with:

CapsuleConnection with ReAct Deep Dive
03 — Plan-and-ExecuteSolves ReAct's "no advance planning" limitation
04 — Task DecompositionSolves the "inefficiency on repetitive tasks" limitation
05 — ReflectionSolves the "no self-evaluation" limitation
06 — Reasoning TracesFormalizes the debugging you did by hand here
07 — Comparing PatternsCompares the trade-offs you mentioned with empirical data

Every ReAct limitation you identified has an entire capsule dedicated to solving it.


Troubleshooting

Problem 1: The agent answers without using tools when it should search

Symptom: You ask it for recent data ("Bitcoin's price today") and it answers with training data without calling any tool.

Cause: The model "believes" it knows the answer. Without a system prompt that forces tool use for certain kinds of data, the model optimizes for speed and answers directly.

Solution: Add explicit instructions in the prompt:

agent = create_react_agent(
    model, tools,
    prompt="""MANDATORY RULE: For any data that changes over time 
(prices, statistics, recent events, software versions), 
ALWAYS use web_search before answering. NEVER answer this kind of data from memory."""
)

Problem 2: The agent loops repeating the same search

Symptom: The trace shows 3+ calls to web_search with the same query or very similar queries.

Cause: The tool result doesn't meet the agent's need, but the agent doesn't know how to reformulate the search.

Solution: Implement repetition detection in a custom StateGraph:

def detect_loop(state: AgentState) -> str:
    tool_messages = [m for m in state["messages"] if hasattr(m, "tool_calls") and m.tool_calls]
    if len(tool_messages) >= 2:
        last_calls = [tc["args"] for tc in tool_messages[-1].tool_calls]
        prev_calls = [tc["args"] for tc in tool_messages[-2].tool_calls]
        if last_calls == prev_calls:
            return "force_respond"
    last = state["messages"][-1]
    if hasattr(last, "tool_calls") and last.tool_calls:
        return "tools"
    return "end"

Problem 3: recursion_limit is reached and there's no answer

Symptom: The agent raises GraphRecursionError and the user gets no answer at all.

Cause: recursion_limit is too low for the complexity of the task, or the agent doesn't converge.

Solution: Combine a reasonable limit with the fallback you saw in the stop conditions section:

def safe_invoke(agent, query: str):
    try:
        return agent.invoke(
            {"messages": [("user", query)]},
            {"recursion_limit": 20}
        )
    except Exception:
        return agent.invoke(
            {"messages": [
                ("user", query),
                ("user", "Answer with the information you have available now, without searching further.")
            ]},
            {"recursion_limit": 3}
        )

Problem 4: The agent consistently picks the wrong tool

Symptom: It uses web_search for calculations or calculator for factual questions.

Cause: The tool docstrings aren't descriptive enough or they overlap.

Solution: Improve the docstrings so they're mutually exclusive:

@tool
def web_search(query: str) -> str:
    """Search the internet for up-to-date FACTUAL information. 
    Use for: news, current data, definitions, people, events.
    DON'T use for: mathematical calculations, unit conversions."""
    ...

@tool
def calculator(expression: str) -> str:
    """Evaluate MATHEMATICAL expressions. 
    Use for: additions, multiplications, percentages, numeric conversions.
    DON'T use for: searching for information or factual data."""
    ...

The "DON'T use for" lines are as important as the "Use for" ones — they give the model clear exclusion signals.

Problem 5: Streaming shows nothing for several seconds

Symptom: When you use agent.stream(), there are long pauses (10-30s) with no visible output.

Cause: The LLM is processing (generating the answer or deciding which tool to call). Streaming at the stream_mode="updates" level shows updates per node, not per token. The pause is the agent node waiting on the LLM.

Solution: Use stream_mode="messages" for token-level streaming:

for event in agent.stream(
    {"messages": [("user", "Research ReAct")]},
    {"recursion_limit": 20},
    stream_mode="messages"
):
    msg, metadata = event
    if msg.content:
        print(msg.content, end="", flush=True)

Exercises

Exercise 1: Inspect the reasoning chain (Easy)

Run the following agent and classify each message of the trace as INPUT, DECISION→ACT, OBSERVATION, or DECISION→ANSWER. Identify: how many iterations of the ReAct loop were there? Did the model make parallel tool calls?

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def search(query: str) -> str:
    """Search the web for information."""
    return f"Python 3.13 was released in October 2024. FastAPI is the most popular Python web framework."

@tool
def calc(expression: str) -> str:
    """Compute a mathematical expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [search, calc])
result = agent.invoke({"messages": [("user", "What's the most recent version of Python and how much is 3.13 * 100?")]})

for i, msg in enumerate(result["messages"]):
    print(f"[{i}] {msg.__class__.__name__}: {msg.content[:120] if msg.content else 'tool_calls: ' + str([(tc['name'], tc['args']) for tc in msg.tool_calls])}")
View solution

The typical trace shows:

[0] HumanMessage: What's the most recent version of Python and how much is 3.13 * 100?
[1] AIMessage: tool_calls: [('search', {'query': '...'}), ('calc', {'expression': '3.13 * 100'})]
[2] ToolMessage: Python 3.13 was released in October 2024...
[3] ToolMessage: 313.0
[4] AIMessage: The most recent version of Python is 3.13... and 3.13 × 100 = 313

Classification:

  • [0]INPUT: the user's question
  • [1]DECISION→ACT: the model chose to call 2 tools at the same time (parallel tool calls: YES)
  • [2]OBSERVATION: result of search
  • [3]OBSERVATION: result of calc
  • [4]DECISION→ANSWER: the model synthesized and answered (no more tool_calls)

ReAct loop iterations: 2. First: the model receives the input and acts (tool calls). Second: the model receives the observations and answers.

Parallel tool calls: Yes — in step [1], the model called search and calc at the same time. That's possible because the two queries are independent.

Exercise 2: Effect of the system prompt on behavior (Medium)

Create two agents with opposite prompts: one that's "aggressive" about searching (minimum 2 searches per question) and one that's "lazy" (answers from memory whenever it can, searches only if it has no idea). Run both with the query "What is LangGraph?" and compare: number of tool calls, total number of messages, and the content of the answer.

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def web_search(query: str) -> str:
    """Search the web for up-to-date information."""
    return f"LangGraph is a LangChain framework for building agents as state graphs. Stable version: 1.0+. It uses StateGraph, nodes, and edges."

model = init_chat_model("openai:gpt-4.1-mini")

# Create the two agents with opposite prompts and compare
# Your code here...
View solution
aggressive = create_react_agent(
    model, [web_search],
    prompt="Always search for information before answering. Run at least 2 searches with different queries for every question. Never answer from memory."
)

lazy = create_react_agent(
    model, [web_search],
    prompt="Answer with your own knowledge whenever you can. Search only if you truly have no idea about the topic. Prioritize speed over thoroughness."
)

query = {"messages": [("user", "What is LangGraph?")]}

result_agg = aggressive.invoke(query, {"recursion_limit": 20})
result_lazy = lazy.invoke(query, {"recursion_limit": 20})

def count_tool_calls(result):
    return sum(len(m.tool_calls) for m in result["messages"] if hasattr(m, "tool_calls") and m.tool_calls)

print(f"Aggressive — Messages: {len(result_agg['messages'])}, Tool calls: {count_tool_calls(result_agg)}")
print(f"Lazy — Messages: {len(result_lazy['messages'])}, Tool calls: {count_tool_calls(result_lazy)}")
print(f"\nAggressive answer: {result_agg['messages'][-1].content[:200]}")
print(f"\nLazy answer: {result_lazy['messages'][-1].content[:200]}")

Typical results:

  • Aggressive: 5-7 messages, 2-3 tool calls. Answer with data from the search.
  • Lazy: 2-3 messages, 0-1 tool calls. Answer from the model's memory.

The aggressive agent probably has more up-to-date data (version, recent features). The lazy one answers faster but may have stale data. The prompt fundamentally changed when the model decides to act vs answer.

Exercise 3: Implement loop detection (Medium)

Build a custom StateGraph with a detection node that identifies when the agent has called the same tool with the same arguments more than once. If it detects a repetition, force the agent to answer with what it has.

from dotenv import load_dotenv
load_dotenv()

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage, ToolMessage
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def web_search(query: str) -> str:
    """Search the web for information."""
    return f"Generic results for: {query}"

tools = [web_search]
tools_by_name = {t.name: t for t in tools}
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)

class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]

# Implement: agent_node, tool_node, and a routing function that detects loops
# Your code here...
View solution
def agent_node(state: AgentState) -> dict:
    return {"messages": [model_with_tools.invoke(state["messages"])]}

def tool_node(state: AgentState) -> dict:
    last = state["messages"][-1]
    results = []
    for tc in last.tool_calls:
        if tc["name"] in tools_by_name:
            result = tools_by_name[tc["name"]].invoke(tc["args"])
            results.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
        else:
            results.append(ToolMessage(content=f"Tool '{tc['name']}' not available", tool_call_id=tc["id"]))
    return {"messages": results}

def route_with_loop_detection(state: AgentState) -> str:
    last = state["messages"][-1]
    
    if not (hasattr(last, "tool_calls") and last.tool_calls):
        return "end"
    
    current_calls = set()
    for tc in last.tool_calls:
        current_calls.add((tc["name"], str(tc["args"])))
    
    previous_ai_messages = [
        m for m in state["messages"][:-1]
        if hasattr(m, "tool_calls") and m.tool_calls
    ]
    
    for prev_msg in previous_ai_messages:
        prev_calls = set()
        for tc in prev_msg.tool_calls:
            prev_calls.add((tc["name"], str(tc["args"])))
        if current_calls == prev_calls:
            print("[LOOP DETECTED] Forcing a final answer.")
            return "force_end"
    
    return "tools"

def force_respond(state: AgentState) -> dict:
    from langchain_core.messages import HumanMessage
    force_msg = HumanMessage(
        content="You have repeated the same searches. Answer now with the information you already have."
    )
    response = model.invoke(state["messages"] + [force_msg])
    return {"messages": [response]}

graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_node("force_respond", force_respond)

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_with_loop_detection, {
    "tools": "tools",
    "end": END,
    "force_end": "force_respond"
})
graph.add_edge("tools", "agent")
graph.add_edge("force_respond", END)

agent = graph.compile()
result = agent.invoke({"messages": [("user", "Search for information about AI agents")]})
print(result["messages"][-1].content)

The route_with_loop_detection function compares the current tool_calls with all previous ones. If it finds an exact duplicate (same tool, same arguments), it redirects to force_respond, which injects a message forcing the final answer. The agent never enters an infinite loop.

Exercise 4: Stop condition with a token budget (Hard)

Implement an agent with a custom StateGraph that tracks the tokens consumed and stops when it exceeds a budget. The state must include total_tokens: int and token_budget: int. The routing must check the budget before sending to tools.

# Hint: use response.usage_metadata to get the tokens of each LLM call
# Your agent must:
# 1. Track accumulated tokens
# 2. If it exceeds the budget: generate a partial answer with what it has
# 3. Print a log of the tokens consumed at each step
View solution
from dotenv import load_dotenv
load_dotenv()

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage, ToolMessage, HumanMessage
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def web_search(query: str) -> str:
    """Search the web for information."""
    return f"Results for '{query}': relevant information about the topic."

tools_list = [web_search]
tools_by_name = {t.name: t for t in tools_list}
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools_list)

class BudgetState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    total_tokens: int
    token_budget: int

def agent_node(state: BudgetState) -> dict:
    response = model_with_tools.invoke(state["messages"])
    tokens = 0
    if hasattr(response, "usage_metadata") and response.usage_metadata:
        tokens = response.usage_metadata.get("total_tokens", 0)
    new_total = state.get("total_tokens", 0) + tokens
    print(f"  [TOKENS] This step: {tokens} | Accumulated: {new_total} / {state.get('token_budget', 5000)}")
    return {"messages": [response], "total_tokens": new_total}

def tool_node(state: BudgetState) -> dict:
    last = state["messages"][-1]
    results = []
    for tc in last.tool_calls:
        result = tools_by_name[tc["name"]].invoke(tc["args"])
        results.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
    return {"messages": results}

def budget_respond(state: BudgetState) -> dict:
    msg = HumanMessage(content="Token budget exceeded. Answer with the information available.")
    response = model.invoke(state["messages"] + [msg])
    return {"messages": [response]}

def route_with_budget(state: BudgetState) -> str:
    if state.get("total_tokens", 0) >= state.get("token_budget", 5000):
        print("  [BUDGET] Limit reached — forcing an answer")
        return "budget_stop"
    last = state["messages"][-1]
    if hasattr(last, "tool_calls") and last.tool_calls:
        return "tools"
    return "end"

graph = StateGraph(BudgetState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_node("budget_respond", budget_respond)

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_with_budget, {
    "tools": "tools",
    "end": END,
    "budget_stop": "budget_respond"
})
graph.add_edge("tools", "agent")
graph.add_edge("budget_respond", END)

budget_agent = graph.compile()
result = budget_agent.invoke({
    "messages": [("user", "Research the trends in AI agents in depth")],
    "total_tokens": 0,
    "token_budget": 2000
})
print(f"\nAnswer: {result['messages'][-1].content[:200]}")
print(f"Total tokens: {result['total_tokens']}")

The agent tracks tokens at each step and, when it exceeds the budget, redirects to budget_respond, which generates a partial answer with what's available. The user always gets something useful — never an exception.

Exercise 5: Empirical comparison of prompts (Hard)

Create a benchmark_prompts function that takes a list of system prompts and a list of test queries. For each prompt×query combination, run the agent and record: number of tool calls, number of messages, and the length of the answer. Present the results in a table. Use at least 3 different prompts and 3 queries.

# Your function must:
# 1. Create one agent per prompt
# 2. Run each query on each agent
# 3. Record metrics
# 4. Print a results table
View solution
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def web_search(query: str) -> str:
    """Search the web for up-to-date information."""
    return f"Information found about: {query}. Relevant and up-to-date data."

model = init_chat_model("openai:gpt-4.1-mini")

prompts = {
    "minimal": "Answer briefly. Use tools only if indispensable.",
    "balanced": "Search for relevant information and give a complete but concise answer.",
    "thorough": "Research exhaustively. Run multiple searches. Verify the data. Give detailed answers with sources."
}

queries = [
    "What is LangGraph?",
    "What are the best practices for AI agents in production?",
    "Compare ReAct with Plan-and-Execute"
]

def benchmark_prompts(prompts: dict, queries: list, tools: list):
    results = []
    
    for prompt_name, prompt_text in prompts.items():
        agent = create_react_agent(model, tools, prompt=prompt_text)
        
        for query in queries:
            try:
                result = agent.invoke(
                    {"messages": [("user", query)]},
                    {"recursion_limit": 15}
                )
                
                tool_calls = sum(
                    len(m.tool_calls) for m in result["messages"]
                    if hasattr(m, "tool_calls") and m.tool_calls
                )
                num_msgs = len(result["messages"])
                resp_len = len(result["messages"][-1].content)
                
                results.append({
                    "prompt": prompt_name,
                    "query": query[:40],
                    "tool_calls": tool_calls,
                    "messages": num_msgs,
                    "resp_length": resp_len
                })
            except Exception as e:
                results.append({
                    "prompt": prompt_name,
                    "query": query[:40],
                    "tool_calls": -1,
                    "messages": -1,
                    "resp_length": 0
                })
    
    print(f"{'Prompt':<12} {'Query':<42} {'Tools':>5} {'Msgs':>5} {'Resp Len':>9}")
    print("-" * 75)
    for r in results:
        print(f"{r['prompt']:<12} {r['query']:<42} {r['tool_calls']:>5} {r['messages']:>5} {r['resp_length']:>9}")

benchmark_prompts(prompts, queries, [web_search])

Example output:

Prompt       Query                                      Tools  Msgs  Resp Len
---------------------------------------------------------------------------
minimal      What is LangGraph?                            0     2       180
minimal      What are the best practices for AI agent      1     4       250
minimal      Compare ReAct with Plan-and-Execute           0     2       300
balanced     What is LangGraph?                            1     4       350
balanced     What are the best practices for AI agent      1     4       420
balanced     Compare ReAct with Plan-and-Execute           1     4       380
thorough     What is LangGraph?                            2     6       550
thorough     What are the best practices for AI agent      3     8       680
thorough     Compare ReAct with Plan-and-Execute           2     6       600

The pattern is clear: the more aggressive the prompt → the more tool calls → the more messages → the longer the answers. "minimal" doesn't even search in 2 of 3 queries. "thorough" runs 2-3 searches per query. The data lets you pick the prompt based on your cost vs quality priorities.


Summary

In this capsule you learned:

  • What create_react_agent does under the hood: it builds a StateGraph with agent and tools nodes, injects a system prompt, and configures routing based on the presence of tool_calls
  • How the model decides to think vs act: the decision is binary — it returns tool_calls (act) or content with no tool_calls (answer). The Thoughts of the original ReAct paper are implicit in LangGraph's implementation
  • The system prompt controls the reasoning: a research prompt generates 3-4 tool calls; a conservative prompt generates 0-1. The prompt isn't cosmetic — it changes real metrics of cost, latency, and quality
  • Stop conditions beyond recursion_limit: budget-based (tokens), quality-based (automatic evaluation), loop detection (repeated tool calls), and fallback responses (a partial answer when the limit is hit)
  • Debugging the reasoning chain: manual message inspection (classifying each step as INPUT/DECISION/OBSERVATION), real-time streaming, and LangSmith for full traces with costs and latencies
  • Diagnosable failure patterns: tool loop, premature termination, wrong tool, over-investigation — each with an identifiable cause and a specific fix
  • ReAct's structural limitations: no advance planning, no self-evaluation, inefficient on repetitive tasks, context that grows without limit

Next capsule: Plan-and-Execute Agents — the first pattern that solves ReAct's most important limitation: the lack of advance planning. You'll learn to separate the planning phase (decomposing the task into steps) from the execution phase (running each step), with the ability to re-plan when a step fails.


Additional Resources

  1. ReAct Paper — Synergizing Reasoning and Acting in Language Models — The original paper by Yao et al. (2022) that formalized Thought→Action→Observation
  2. LangGraph create_react_agent Reference — The full API of the prebuilt agent: parameters, configuration, and customization
  3. LangGraph Streaming Guide — How to implement streaming at the node level and at the token level
  4. LangSmith Documentation — Setup, tracing, and dashboard for agent observability
  5. LangGraph ReAct Agent Tutorial — Step-by-step tutorial from the LangGraph team