This article is published in English.
Building a ReAct Research Agent in LangGraph: Brain, Hands, Router
Learn how to implement the ReAct reason-act-observe loop as a LangGraph sub-graph, with forced reflection, iteration budgets and parallel scatter-gather research.
A single LLM call cannot research a question it knows nothing about. A useful research agent has to search, read what came back, decide what is still missing, and search again until it has enough to answer. The ReAct pattern gives that behaviour a precise shape, and LangGraph lets you express it as a small, explicit graph instead of a tangle of while-loops. By the end of this walkthrough you will understand every node of a working ReAct research sub-graph, know where it can fail, and have a list of upgrades that make it safe to run in production.
The design follows the researcher component of an open-source project called deep-research-agent. At a high level, one run looks like this:
- A research question arrives from the user.
- The LLM, acting as the "brain", reasons about the question and picks a tool to call.
- A tool executor, the "hands", runs that tool and hands back an observation.
- A router inspects the brain's latest message to see whether more tool calls were requested.
- If they were, control goes back to step 2.
- If not, the accumulated research is compressed into a final answer.
What the ReAct pattern actually is
ReAct stands for "Reason and Act". It comes from the research paper "ReAct: Synergizing Reasoning and Acting in Language Models" (first released in 2022 and presented at ICLR 2023). The central observation is that language models do better when they interleave two kinds of steps: thinking about what to do next, and using a tool to get real information. Each half fails on its own. A model that only reasons will confidently invent facts, because nothing grounds it. A model that only acts will call tools mechanically without interpreting what they return.
Google Cloud's architecture guidance frames the pattern as a loop over natural-language steps that keeps going until an exit condition is met. In practice it breaks down into three repeating phases:
- Thought. The model looks at everything collected so far and decides whether the request is already answered or what to do next.
- Action. Based on that reasoning, it either calls a tool to gather more data or writes a final answer, which ends the loop.
- Observation. The tool's output comes back and is kept in the conversation. Because earlier observations stay visible, the model can build on them instead of repeating searches or forgetting context.
This is close to how an experienced engineer investigates an unfamiliar problem: look something up, think about it, look up the next thing, and only then write the conclusion. Anthropic's tool-use documentation describes the same mechanic from the API side: the model replies with a tool-use request, your application executes it and sends back the result, and the exchange repeats. The loop is identical whether the model underneath is Claude, GPT or Gemini.
That is the important point to hold on to: ReAct is a pattern, not a library feature. LangGraph happens to give you a tidy way to express it with a StateGraph, but you could implement the same loop with any model and any orchestration code. The deep-research-agent project packages it as a LangGraph sub-graph, which makes it composable: a larger system can call it as a single unit. If you want a conceptual refresher before diving into code, see our overview of how AI agents combine reasoning with real-world actions.
The three components and the state they share
The researcher loop is built from three small functions, each with one job. The brain (llm_call) reads the current conversation and responds with either plain text or tool-call requests. The hands (tool_node) execute any requested tool calls and return their results. The router (should_continue) decides whether another round is needed. Keeping these responsibilities separate means you can unit-test each one, swap the model or the tools independently, and reason about the loop by reading three short functions.
Defining the researcher state
Everything that flows through a LangGraph graph lives in a state object, declared as a TypedDict. The researcher state has five fields. researcher_messages holds the running conversation; it is wrapped in Annotated with the add_messages reducer, which tells LangGraph to merge new messages into the existing list rather than overwrite it. tool_call_iterations counts loop cycles, research_topic records what the agent is investigating, compressed_research receives the final summary, and raw_notes collects notes using operator.add as its reducer, so lists returned by different nodes are concatenated.
# Define the state that flows through the entire ReAct loop
from typing import Annotated, Sequence, List, TypedDict
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage
import operator
class ResearcherState(TypedDict):
# The message history accumulates as the loop runs
researcher_messages: Annotated[Sequence[BaseMessage], add_messages]
# Tracks how many tool call iterations have occurred
tool_call_iterations: int
# The topic this researcher is investigating
research_topic: str
# The final compressed output after the loop ends
compressed_research: str
# Raw notes collected during research
raw_notes: Annotated[List[str], operator.add]
The reducers are what make the loop work. Every time the brain speaks or the hands return results, a node returns only the new messages, and the reducer appends them. Without add_messages, each node would replace the history, and the model would lose everything it had learned in earlier iterations.
The project also declares a narrower output schema. It controls which fields leave the sub-graph when a parent graph calls it.
# Output schema controls what the parent graph sees
class ResearcherOutputState(TypedDict):
compressed_research: str
raw_notes: Annotated[List[str], operator.add]
researcher_messages: Annotated[Sequence[BaseMessage], add_messages]
Splitting internal state from output state is a good LangGraph habit. Bookkeeping such as tool_call_iterations matters only inside the loop, so the parent graph never sees it. The parent receives the compressed research, the raw notes and the messages, which keeps the interface between graphs small and deliberate.
Why a TypedDict rather than a plain dictionary? It documents exactly what data moves through the graph and lets type checkers catch misspelled keys. The add_messages reducer adds behaviour on top: it appends new messages, and when an incoming message carries the ID of one already in the list, it replaces that message instead of duplicating it, so ordering and identity stay consistent.
The brain: llm_call
The brain is where reasoning happens. It builds a prompt from a system message plus the entire message history and asks the model what to do next. Note that the source labels this snippet as JavaScript; it is Python.
# The "brain" of the researcher: analyzes current state and decides next action
from langchain_core.messages import SystemMessage
def llm_call(state: ResearcherState):
# Invoke the LLM with the system prompt and full conversation history
return {
"researcher_messages": [
model_with_tools.invoke(
[SystemMessage(content=research_agent_prompt.format(date=get_today_str()))]
+ state["researcher_messages"]
)
]
}
Three things happen here. First, a SystemMessage is created from research_agent_prompt, with today's date injected via get_today_str() so the model can judge how current its information is. Second, that system message is prepended to everything in state["researcher_messages"], so the model always sees the full context. Third, model_with_tools.invoke() sends it all to a model that has tools attached. The reply is either plain text, which signals that research is finished, or one or more tool-call requests, which signal that more information is needed. The function returns the reply wrapped in a list under researcher_messages, and the reducer appends it.
The system prompt is rebuilt on every call rather than stored in state. That keeps the history clean and guarantees the instructions are always first, even after many iterations.
model_with_tools is created during setup. Rather than importing a vendor class such as ChatOpenAI, the project uses LangChain's init_chat_model(), which takes a provider-prefixed model string.
# Initialize the model using LangChain's provider-agnostic helper
from langchain.chat_models import init_chat_model
# The project uses different models for different tasks
model = init_chat_model(model="openai:gpt-4o")
# Bind the research tools so the model knows what actions are available
model_with_tools = model.bind_tools([tavily_search, think_tool])
The provider prefix is the advantage: moving from "openai:gpt-4o" to an Anthropic model (a string of the form "anthropic:claude-sonnet-4-20250514") is a configuration change, not an import change. Check your provider's current model list, since identifiers change over time. .bind_tools() then advertises the available actions to the model. Two tools are bound: tavily_search, which performs web searches through the Tavily API, and think_tool, a reflection tool covered below.
The hands: tool_node
The hands take the tool calls from the brain's last message and run them. This snippet is also Python despite its JavaScript label.
# The "hands" of the researcher: executes all tool calls from the brain
from langchain_core.messages import ToolMessage
def tool_node(state: ResearcherState):
# Get the tool calls from the last message (the brain's output)
tool_calls = state["researcher_messages"][-1].tool_calls
observations = []
# Execute each tool call and collect raw results
for tool_call in tool_calls:
tool = tools_by_name[tool_call["name"]]
observations.append(tool.invoke(tool_call["args"]))
# Convert raw results into properly formatted ToolMessage objects
tool_outputs = [
ToolMessage(
content=str(observation),
name=tool_call["name"],
tool_call_id=tool_call["id"]
)
for observation, tool_call in zip(observations, tool_calls)
]
return {"researcher_messages": tool_outputs}
The function reads tool_calls from the most recent message, looks up each requested tool by name in a tools_by_name dictionary, and invokes it with the model-supplied arguments. The second half is what matters for correctness: each raw result is wrapped in a ToolMessage with three fields. content holds the stringified result, name records which tool produced it, and tool_call_id ties the result to the exact request that triggered it. Model APIs require that ID; a tool result that cannot be matched to a request is rejected, and a request with no matching result leaves the conversation in an invalid state.
The tools_by_name mapping is used but never defined in the project's notebook. You would build it yourself, for example as {"tavily_search": tavily_search, "think_tool": think_tool}, or with a dictionary comprehension over the tool list so the names always stay in sync with what was bound.
If the brain asks tavily_search to look up "latest AI research", the hands run the query and return a message shaped like this:
ToolMessage(content="Search results for 'latest AI research': ...", name="tavily_search", tool_call_id="call_abc123")
Because tool calls are executed sequentially in a plain for loop, a turn with several searches takes as long as all of them combined. That is acceptable for a demo; later we will look at making this node more robust.
The router: should_continue
The router is the smallest function and the one that governs the whole loop. It looks at the last message and chooses the next node.
# The "router": determines whether to loop again or finish
from typing import Literal
def should_continue(state: ResearcherState) -> Literal["tool_node", "compress_research"]:
# Check the last message in the conversation
messages = state["researcher_messages"]
last_message = messages[-1]
# If the brain requested tool calls, continue the loop
if last_message.tool_calls:
return "tool_node"
# If no tool calls, the brain is done researching
return "compress_research"
If the brain's latest message contains tool_calls, the router returns "tool_node" and the loop continues. If the brain produced only text, it returns "compress_research", which leaves the loop and moves to a summarisation step. The decision is based purely on the model's output; the router itself has no opinion about whether the research is good enough.
The Literal["tool_node", "compress_research"] return annotation tells LangGraph which destinations are possible. LangGraph uses it to know the router's branches (for example when drawing the graph or when no explicit mapping is supplied), so it is more than documentation. It does not, however, stop the function from returning some other string at runtime; that would surface as an error when the branch is taken.
Why route to a compression step instead of ending straight away? Because this sub-graph is designed to be called by a supervisor agent. The supervisor needs a concise, structured answer, not a long transcript of searches, reflections and tool payloads. Compressing inside the sub-graph keeps the supervisor's own context small.
Wiring the loop with a StateGraph
With the three functions in place, the next step is to connect them. Instead of writing the control flow by hand, you declare nodes and edges, and LangGraph runs the graph. Execution starts at the brain, passes through the router, and either goes to the hands (after which it always returns to the brain) or exits through compress_research.
Assembling and compiling the graph
This snippet is Python as well, despite the JavaScript label.
# Build the ReAct loop as a LangGraph StateGraph
from langgraph.graph import StateGraph, START, END
# Initialize the graph with both input state and output schema
agent_builder = StateGraph(ResearcherState, output_schema=ResearcherOutputState)
# Add the three nodes to the graph
agent_builder.add_node("llm_call", llm_call) # The brain
agent_builder.add_node("tool_node", tool_node) # The hands
agent_builder.add_node("compress_research", compress_research) # The exit point
# Wire the entry point: execution starts at the brain
agent_builder.add_edge(START, "llm_call")
# Wire the router: after the brain thinks, decide what to do next
agent_builder.add_conditional_edges(
"llm_call",
should_continue,
{
"tool_node": "tool_node",
"compress_research": "compress_research",
},
)
# Wire the loop: after the hands act, always go back to the brain
agent_builder.add_edge("tool_node", "llm_call")
# Wire the exit: after compression, end the graph
agent_builder.add_edge("compress_research", END)
# Compile the graph into a runnable agent
researcher_agent = agent_builder.compile()
Reading it top to bottom:
StateGraph(ResearcherState, output_schema=ResearcherOutputState)creates the graph with the full internal state and the restricted output schema that parent graphs will see.- Three nodes are registered:
llm_call,tool_nodeandcompress_research. add_edge(START, "llm_call")makes the brain the entry point.add_conditional_edgesattaches the router to the brain's output, with a dictionary mapping each possible return value to a node.- A fixed edge from
tool_nodeback tollm_callcloses the loop. add_edge("compress_research", END)terminates the graph once the summary is written.
.compile() turns the declaration into a runnable object. It is named researcher_agent rather than just agent because, in the full project, it is a sub-graph invoked by the supervisor.
The mapping passed to add_conditional_edges deserves attention. Its keys, "tool_node" and "compress_research", must match exactly what should_continue returns, and its values must be real node names. Compilation checks that the mapped destinations exist, so a typo in a node name fails early instead of halfway through a run. A router that returns a value missing from the map, on the other hand, only fails when that branch executes, so test the router on both branches. Even so, this is far easier to validate than an equivalent hand-rolled while loop, where a wrong branch simply behaves incorrectly.
Visualising the cycle
The compiled graph produces this flow:
START
│
▼
llm_call (Brain reasons about the query)
│
├── has tool_calls? ──► tool_node (Hands execute tools)
│ │
│ └──► llm_call (Back to brain)
│
└── no tool_calls? ──► compress_research (Summarize and exit)
This is the classic ReAct cycle. The brain and the hands can alternate as many times as the model keeps requesting tools. Each pass adds observations to the state, so each new decision is made with more information than the last.
Adding checkpointing
The example so far is a standalone, in-memory agent. For anything long-running, you attach a checkpointer at compile time so the graph's state is saved after each step.
# Production: add checkpointing for fault tolerance
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
agent = agent_builder.compile(checkpointer=checkpointer)
MemorySaver keeps checkpoints in process memory, which is ideal for development and tests but disappears on restart. For production, LangGraph offers persistent checkpointers such as PostgresSaver and SqliteSaver, which let an interrupted run resume from its last saved step and leave a record of every state transition. One practical detail: once a graph has a checkpointer, each invoke call needs a thread identifier in its config (for example {"configurable": {"thread_id": "..."}}) so LangGraph knows which saved conversation to load and update.
Hardening the loop: reflection, budgets and parallelism
A bare ReAct loop has two failure modes that show up quickly. It can loop without converging, spending tokens and API credits on search after search. Or it can race through searches without really digesting them, producing shallow results. The project addresses both, and then scales the loop out, with three additions.
Forced reflection with think_tool
The most interesting addition is a tool that performs no external action at all. Its only purpose is to make the model stop and think in writing.
# A tool that forces the agent to pause and reflect
from langchain_core.tools import tool
@tool(parse_docstring=True)
def think_tool(reflection: str) -> str:
"""Tool for strategic reflection on research progress and decision-making.
Use this tool after each search to analyze results and plan next steps
systematically. This creates a deliberate pause in the research workflow
for quality decision-making.
Args:
reflection: Your detailed reflection on research progress, findings,
gaps, and next steps.
Returns:
Confirmation that reflection was recorded for decision-making.
"""
return f"Reflection recorded: {reflection}"
think_tool receives a reflection string and returns it prefixed with a confirmation. The long docstring is not decoration: with parse_docstring=True, LangChain extracts the tool description and the argument description from the docstring, and that text is what the model reads when choosing among tools. The effect comes from the system prompt, which tells the agent to call think_tool after every search. That forces the model to state what it just learned, what is still missing and what it plans to do next.
Without this step, agents tend to chain searches without synthesising anything in between. Anthropic's engineering team has made a related observation: agents that can review and correct their own output are more reliable, because they catch mistakes before those mistakes compound and can steer back when they drift. think_tool builds a small self-review checkpoint into every iteration. It is cheap, since it costs only the tokens of the reflection itself plus one extra round trip, and it keeps the model anchored to its goal.
Suppose that after a search the agent reflects that it has found three RAG indexing approaches but still lacks benchmarks comparing them. The tool simply echoes that reflection back with its confirmation prefix:
Reflection recorded: The search results show three approaches to RAG indexing. I still need to find benchmarks comparing them.
That string is stored as a ToolMessage, so on the next iteration the brain reads its own plan back. Why a tool rather than simply asking the model to "think step by step"? A tool call is a discrete, visible event in the trace, it lands in the message history in a predictable format, and the prompt can require it at a specific point in the loop.
Budget controls in the system prompt
The second addition caps how much searching the agent does:
Budget rules embedded in the system prompt:
- Simple queries: 2 to 3 search calls maximum
- Complex queries: up to 5 search calls maximum
- Always stop after 5 calls if sources are not found
These limits live in the system prompt, not in code. The model is told how many searches suit a simple or a complex query and when to give up. It is a pragmatic choice: no counters or extra graph logic, just instructions the model is trusted to follow.
The trade-off is real. Google Cloud's guidance points out that the iterative style adds latency compared with a single query, and that results depend heavily on the quality of the model. A search budget directly bounds that latency by limiting how many rounds can happen.
Prompt-based limits are soft, though. A model can misjudge complexity or simply ignore the instruction. The state already has a tool_call_iterations field, so it is straightforward to add a hard ceiling that the router enforces. A robust setup uses both: the prompt shapes normal behaviour, and code guarantees an upper bound. Our article on bounded agentic loops for LLM tool use explores the same idea in TypeScript.
Scatter-gather with a supervisor
The third addition treats the whole ReAct loop as a reusable worker. A supervisor agent splits a broad question into sub-questions and runs a separate researcher sub-graph for each one, in parallel.
Supervisor receives: "Compare the economic impact of AI on healthcare vs. education"
Supervisor creates two parallel research tasks:
├── ReAct Agent 1: Research AI impact on healthcare
└── ReAct Agent 2: Research AI impact on education
Both agents run their ReAct loops independently.
Results are gathered and synthesized by the supervisor.
This is the scatter-gather pattern: fan the work out to independent workers, then collect and merge their results. Each researcher has its own state, tools and budget, so one sub-question's long search history never pollutes another's context. The supervisor only sees the compressed outputs.
The supervisor is itself a small graph. Instead of a fixed for loop over sub-questions, it relies on LangGraph's Command return type, which lets a node update state and name the next node in one step:
Supervisor sub-graph nodes:
├── supervisor (LLM decides what to do next)
├── supervisor_tools (executes supervisor-level tools like ConductResearch)
├── red_team (attacks draft logic to find flaws)
└── context_pruner (clears raw notes to manage context size)
The supervisor_tools node uses Command to route dynamically:
- If research is needed → spawns researcher sub-graphs via ConductResearch tool
- If critique is needed → routes to red_team node
- If context is bloated → routes to context_pruner node
- If research is complete → routes to END
To the supervisor, the researcher is a black box. It calls a ConductResearch tool, and that tool invokes the compiled researcher sub-graph, which runs the complete ReAct loop independently. Around it sit other specialised nodes: a red_team node that attacks the draft's reasoning to find weaknesses, and a context_pruner that clears raw notes when the context grows too large.
Using Command rather than static edges gives the supervisor runtime flexibility. After each step it can choose to launch more researchers, send a draft for critique, prune context, or finish, depending on the current state. The cost is that the routing logic moves into node code, so the graph's shape is less obvious from the edge declarations alone; good logging and tracing become more important.
Tracing a full run
To see the pieces working together, give the agent a moderately complex question. The source labels this call as plain text; it is Python.
# Run the agent with a research question
result = agent.invoke({
"researcher_messages": [
HumanMessage(content="What are the main approaches to reducing hallucination in RAG systems?")
]
})
Two small corrections apply if you run this yourself. The graph compiled earlier is called researcher_agent, so use that name (or the checkpointed agent from the previous section). And if you use the checkpointed version, pass a thread_id in the config as described above.
An illustrative execution trace looks like this. It is a log rather than Python code.
--- Iteration 1 ---
[Brain] Reasoning: I need to search for approaches to reducing RAG hallucination.
[Brain] Tool call: search_tool(query="reducing hallucination in RAG systems approaches")
[Hands] Executing search_tool...
[Hands] Results: Found 5 relevant articles about RAG hallucination reduction.
[Router] Last message has tool_calls? No (think_tool was called)
--- Iteration 2 ---
[Brain] Tool call: think_tool(reflection="The search results mention three main
approaches: better chunking strategies, re-ranking retrieved documents, and
adding citation verification. I should search for specific implementations.")
[Hands] Executing think_tool...
[Router] tool_calls present -> route to tool_node
--- Iteration 3 ---
[Brain] Tool call: search_tool(query="citation verification RAG pipeline implementation")
[Hands] Executing search_tool...
[Router] tool_calls present -> route to tool_node
--- Iteration 4 ---
[Brain] Tool call: think_tool(reflection="I now have solid coverage of the three
main approaches with implementation details. I have enough information to
provide a comprehensive answer.")
[Hands] Executing think_tool...
[Router] tool_calls present -> route to tool_node
--- Iteration 5 ---
[Brain] No tool calls. Generating final response.
[Router] No tool_calls -> route to compress_research
[Compress] Summarizing all findings into structured output.
What the trace shows:
- The agent made two searches and two reflection calls, comfortably inside the prompt's budget.
- Each reflection summarised what had been learned and set up the next search, which is exactly what the forced-reflection rule is meant to produce.
- The agent stopped on its own once it judged that it had enough information: four tool-calling rounds followed by a final text reply.
- The router sent execution to
tool_nodewhenever tool calls were present and tocompress_researchonce they were not. - The final output was a compressed summary rather than the raw conversation.
Read the trace as a sketch, not as literal program output. It names the search tool search_tool although the bound tool is tavily_search, and the router line in the first iteration says no tool calls were made even though a search was requested, which would actually route to tool_node. The overall shape, alternating search and reflection until the model answers in plain text, is the part to take away.
Taking the loop to production
The researcher sub-graph is a solid foundation, but five upgrades make it far more dependable in real deployments:
- Enforce the budget in code. Increment
tool_call_iterationson every pass and haveshould_continueroute tocompress_researchonce a maximum is reached, whatever the model asks for. This is the safety net under the prompt's soft limits. - Stream progress to users. LangGraph's
.astream_events()emits events for node transitions and tool calls, so a UI can show messages such as "Searching for..." or "Analysing results..." while the loop runs instead of a spinner. - Recover from tool errors. Today a network timeout or rate-limit error inside a tool would crash the run. Wrap each invocation in
try/exceptand return aToolMessagedescribing the error. The brain then sees the failure and can retry with different arguments or change approach. Anthropic's documentation recommends reporting tool errors back to the model in this way. - Persist long research runs. Use
PostgresSaverfor tasks that span many iterations, so an interrupted run resumes where it stopped and every reasoning step and tool call remains auditable. - Add human-in-the-loop breakpoints. LangGraph can pause at interrupt points and wait for approval. For high-stakes research, pause every N iterations, show a person what has been found, and let them continue, redirect or stop the agent.
Key takeaways
- ReAct is a loop of thought, action and observation; it is framework-agnostic, and LangGraph simply makes the loop explicit and inspectable.
- Three single-purpose nodes (brain, hands, router) plus reducer-backed state are enough for a working research agent.
- Always pair each tool result with its
tool_call_id, and separate internal state from what the sub-graph exposes. - A no-op reflection tool is a cheap, visible way to force synthesis between searches.
- Prompt budgets shape behaviour, but only a code-level cap guarantees termination.
- Once the loop is a compiled sub-graph, a supervisor can fan it out in parallel and treat each researcher as a black box.