Build a Multi-Agent Research Assistant
Lesson 3ReAct: reason before you act
Reason, act, observe: the loop that makes agents reliable
ReAct, reason, act, observe, repeat
Lesson 2 gave your agent a search tool. The agent used it once, blindly, with the raw user query, then answered. That worked until the question had multiple sub-topics.
The root cause: the agent had no say in what to search for. It just forwarded the user query to the search function and hoped the results were sufficient.
ReAct fixes this. Instead of one search-then-answer pass, the LLM drives a loop: it reasons about what it needs, outputs a focused search query, observes the result, and decides whether to search again or answer. The loop continues until the LLM is satisfied, or until a safety limit stops it.
What you're building
ResearchAgent, v0.3
The LLM controls the search loop. It outputs SEARCH: <query> or ANSWER: <text>. The agent executes searches and feeds observations back until the LLM signals it is done.
See it first: the loop
This loop is the whole lesson. The agent decides whether it knows enough; if not, it reasons and searches again. Intelligence comes from the feedback loop, not a single pass.
Lesson 2 went straight from question to one search to answer. Now the agent reasons, acts, observes, and decides whether to go again. That decision, in a loop, is the birth of agency.
Watch it think
Notice the agent does not plan all its searches up front. It chooses each next query from what it just read. That is the difference between a script and an agent.
The memory is just a list
There is no separate "memory". Every turn is appended to one list, and that growing transcript is what the agent re-reads each step.
There is no special memory store. Every reasoning step, search, and observation is appended to one messages list, and the agent re-reads that list on every turn.
Why this beats one-shot retrieval
RAG retrieves once, before it knows what it needs. ReAct retrieves as it learns, deciding the next query from what it just read.
RAG retrieves once, before it knows what it needs. ReAct retrieves adaptively, so a multi-part question gets the multiple, targeted searches it actually requires.
And the new limit you will hit
ReAct stops when it feels done, not when the question is covered. It has no checklist. Lesson 4 gives it one: a plan.
ReAct improvises. It stops when it feels done, with no guarantee it covered everything the question asked. Fixing that needs an explicit plan, Lesson 4.
You are here
You are at L3. Each step adds one capability the step below it lacked.
The protocol
The LLM and the agent communicate through a simple two-token protocol:
| LLM output | Agent action |
|---|---|
SEARCH: <query> | Call search_tool.search(query), append result to conversation, loop |
ANSWER: <text> | Return text, done |
Everything else stays the same: the same LLMClient interface, the same SearchTool, the same messages list. The only new thing is the loop.
Why the messages list is the memory
In Lessons 1 and 2, each run() call built a fresh messages list. In ReAct, the messages list accumulates across steps, each search query and its observation get appended. By step 3, the LLM can see everything it has already looked up and reason about what is still missing.
messages after two search steps:
system: REACT_PROMPT
user: "Compare AlphaCode and Codex..."
assistant: "SEARCH: AlphaCode benchmark evaluation"
user: "OBSERVATION:\n- AlphaCode evaluated on CodeContests\n..."
assistant: "SEARCH: Codex HumanEval results"
user: "OBSERVATION:\n- Codex ~72% pass@1 on HumanEval\n..."
→ LLM call → "ANSWER: ..."The conversation history is the agent's working memory for this task.
The system prompt (given)
The system prompt tells the LLM about the protocol. It must be exact, a single word out of place and the LLM outputs freeform text instead of SEARCH: / ANSWER:.
REACT_PROMPT = """You are a research assistant that works step by step.
At every step you MUST output exactly one of:
SEARCH: <a focused search query>
ANSWER: <your final answer>
Rules:
- Use SEARCH when you need information not yet in your observations.
- Use ANSWER only when your retrieved observations are sufficient to answer.
- Keep each SEARCH query short and focused, one topic per search.
- Never hallucinate. If retrieved context is insufficient, say so in ANSWER.
- Do not repeat a search you have already done."""Your task
Write ResearchAgent.run(): the ReAct loop. The skeleton is in the cell below.
Handle three cases per step: SEARCH: (search + append observation),
ANSWER: (extract text + return), and anything else (return as-is).
Add a max_steps guard. Print a trace line when self.verbose is True.
The trace shows two targeted searches, each scoped to one paper, followed by a synthesised answer. This is what the Lesson 2 agent could not do: it could only pass the verbatim user query to search.
Now look at what the agent doesn't search for
The agent fixed the Lesson 2 wall. But it introduces a new problem that is harder to see: the agent has no plan. It improvises the search sequence step by step, which means it searches for what it thinks to search for, not necessarily everything the question asked.
The new wall, read the trace carefully
The query below has three distinct sub-questions. Run the cell and count how many the agent actually searched for. Then read the ANSWER, does it cover all three?
What just happened
The agent searched twice and answered, but the answer explicitly says training data and citation counts were not retrieved. Read the post-mortem at the bottom of the output.
The agent did not forget to search for those things. It never had a list of what needed answering in the first place. It improvised: searched for what came to mind first, checked if it could answer, and answered.
The structural gap in ReAct
ReAct decides what to search for one step at a time, with no upfront inventory of what the question requires. For simple questions this is fine. For systematic research, where coverage matters and every sub-question must be answered, improvisation is unreliable. The agent answers when it feels ready, not when it has actually covered the question.
The fix: decompose before you search
The solution is a Planner, a separate step that reads the full question and produces an explicit list of sub-questions before any searching begins:
Query: "Compare AlphaCode and Codex on benchmark, training data, and citations"
Planner output:
1. What benchmark did AlphaCode use and what were its results?
2. What benchmark did Codex use and what were its results?
3. What was the training dataset size for AlphaCode?
4. What was the training dataset size for Codex?
5. What is the approximate citation count for each paper by 2024?Then the agent executes each sub-question in order. Coverage is guaranteed, not because the LLM is lucky in its improvisation, but because the plan was explicit from the start.
That is Lesson 4.
Practice it yourself
Build the ReAct loop by hand — parse the protocol, track memory, drive the cycle. Real Python, hidden tests, no setup.
The two-token protocol that keeps the loop deterministic.
Count observations — the transcript is the state.
Reason → act → observe, guarded by max_steps.
Where you are
| Capability | Status |
|---|---|
| LLM call | ✓ Lesson 1 |
| Tool use + RAG | ✓ Lesson 2 |
| Multi-step adaptive search | ✓ This lesson |
| Verbose trace | ✓ This lesson |
| Upfront decomposition + guaranteed coverage | ✗ Lesson 4 |
Quiz
Check your understanding
1 / 6What does ReAct stand for / do, and how does it differ from Lesson 2's RAG?
Practice it yourself
The ReAct loop lives or dies on driving the loop and parsing the model's tool calls. Build those exact pieces, in the browser with hidden tests, no setup:
The exact reason-act-observe loop you just wrote, with the SEARCH/ANSWER dispatch and step guard.
Parse the model's tool request and validate it against a schema, the robust version of the prefix check.
Fix slightly-malformed tool arguments, the real-world failure when an LLM's output isn't quite valid.