ReAct & Planning
ReAct — reason, act, observe, repeat — is the intellectual ancestor of the modern agent loop. Today the pattern is baked into native tool calling, but the ideas (verbalized reasoning, plan-then-act, re-planning on surprise) still decide whether your agent flails or converges.
The 2022 ReAct paper (Yao et al., "ReAct: Synergizing Reasoning and Acting in Language Models") made a simple observation: models that only reason (chain-of-thought) hallucinate facts, and models that only act (emit actions) make impulsive, unrecoverable moves. Interleaving the two — Thought → Action → Observation, repeated — beat both. Before tool-calling APIs existed, this was done entirely with prompting and text parsing.
# Colab cell 1 — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted.
!pip install -q anthropic
import os
try:
from google.colab import userdata
os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
from getpass import getpass
os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))
import re
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"
# A tiny in-memory knowledge base so the actions do real work — no setup,
# no files. search[] finds titles; lookup[] reads one article in full.
KB = {
"ReAct": "ReAct interleaves Thought, Action, and Observation steps. "
"Reason-only models hallucinate facts; act-only models make "
"impulsive, unrecoverable moves. Interleaving the two beats both.",
"Agent loop": "An agent is an LLM calling tools in a loop where the model — "
"not your code — chooses the next action each iteration.",
"Exponential backoff": "On 429/5xx, retry with wait 2**attempt seconds plus "
"jitter, capped at 60s; give up after 5 tries.",
}
def search(query: str) -> str:
words = [w for w in query.lower().split() if len(w) > 2]
hits = [t for t in KB if any(w in t.lower() for w in words)]
# on a miss, name what DOES exist — an empty dead-end invites the model
# to stop searching and answer from memory (ungrounded). See the loop below.
return "Titles: " + ", ".join(hits) if hits else (
"No match. The KB contains: " + ", ".join(KB))
def lookup(title: str) -> str:
return KB.get(title, f"No article titled {title!r}.")
REACT_PROMPT = """Answer the question by interleaving Thought, Action, and
Observation steps.
Available actions:
search[query] - search the knowledge base
lookup[title] - read a full article
finish[answer] - give the final answer
Respond with exactly one Thought and one Action, then STOP:
Thought: <reasoning about what to do next>
Action: <one action>
I will run the action and reply with:
Observation: <result>
Question: {question}"""
def react_step(messages) -> tuple[str, str]:
resp = client.messages.create(
model=MODEL, max_tokens=512,
stop_sequences=["Observation:"], # forbid hallucinating results
messages=messages,
)
text = resp.content[0].text
match = re.search(r"Action: *(\w+)\[(.*)\]", text)
if match is None:
raise ValueError("model broke the ReAct format:\n" + text)
return match.group(1), match.group(2) # e.g. ("search", "ReAct")
def run_react(question: str, max_steps: int = 6) -> str:
messages = [{"role": "user", "content": REACT_PROMPT.format(question=question)}]
for _ in range(max_steps):
action, arg = react_step(messages)
arg = arg.strip().strip("'").strip('"') # models love quoting args
print(f"Action: {action}[{arg}]")
if action == "finish":
return arg
obs = search(arg) if action == "search" else lookup(arg)
print(f"Observation: {obs}")
# feed the action + real observation back as the next turn of context
messages.append({"role": "assistant", "content": f"Action: {action}[{arg}]"})
messages.append({"role": "user", "content": f"Observation: {obs}"})
raise RuntimeError("max steps exceeded")
print(run_react("What problem does the ReAct pattern solve?"))stop_sequences=["Observation:"] cuts the model off before it invents its own observation (early ReAct implementations lived and died by this), and the regex extracts the action from free text — which is exactly the fragile parsing that native tool calling replaced with schema-validated JSON. Everything above react_step is one-time setup (key, a fake KB, the two action implementations) so the cell runs in Colab; run_react is the loop that grounds each Observation in the KB rather than the model's imagination. You should be able to explain this history in an interview, but never build on regex parsing in 2026.stop_sequences=["Observation:"] line from react_step — 'the prompt already says to stop after one Action.' The agent's accuracy quietly collapses over the next week. What is actually happening in the transcripts?Modern tool calling is ReAct with the plumbing formalized: the Action became a tool_use block (typed, validated, no regex), the Observation became a tool_result, and the Thought became text the model emits before its tool calls — or, on models that support it, dedicated extended-thinking blocks. The insight that survives is behavioral, not mechanical: agents that articulate reasoning before acting pick better tools and recover from surprises. A system-prompt line like "before each tool call, state in one sentence what you expect to learn" measurably reduces flailing on hard tasks — at the price of extra output tokens.
On current frontier models the Thought leg has been absorbed even deeper: with adaptive thinking, the model interleaves private reasoning blocks between tool calls automatically — deciding for itself when a step deserves deliberation — and the effort parameter scales how much. So the 2026 version of 'ReAct vs. native tool calling' has three layers to name: text-protocol ReAct (history), typed tool calling with prompted reasoning (the portable baseline), and interleaved thinking (the frontier default, where reasoning is a billed, replayed first-class block — remember from Module 1 that thinking blocks are resent verbatim like any assistant content).
Planning: upfront vs. as-you-go
| Strategy | How it works | Wins when | Fails when |
|---|---|---|---|
| Plan-as-you-go (pure ReAct) | No explicit plan; each iteration decides the next step from accumulated context | Short tasks (≤ ~5 steps); environments where each result reshapes the task | Long tasks — the agent wanders, repeats work, forgets the goal |
| Upfront plan | First call produces a step list; the loop executes with the plan pinned in context | Multi-step research/refactors; anything needing coverage (check A, B, and C) | The plan is built on wrong assumptions and the agent follows it off a cliff |
| Plan + re-plan | Upfront plan, plus an explicit trigger to revise when observations contradict it | Long tasks in uncertain environments — the default for serious agents | Trigger too eager → thrashing; too lazy → plan drift anyway |
The failure mode to name in interviews is plan drift: the environment disagrees with step 2 ("the config file the plan assumed doesn't exist"), but the model keeps marching through steps 3–5 because the stale plan sits in context outranking fresh observations. The fix is making re-planning a first-class, visible action rather than hoping the model improvises.
# Colab cell 2 — run the ReAct cell above first (it installs the SDK and
# creates client and MODEL). This cell swaps the fake KB for a fake repo.
# A tiny in-memory "repo" so list_dir/grep/read_file do real work.
REPO = {
"README.md": "Sample service. Retry policy is configured in config/app.yaml.",
"config/app.yaml": "retries: 5\nbackoff: exponential\ntimeout_s: 30\n",
"src/retry.py": "def backoff(attempt):\n return min(2 ** attempt, 60)\n",
}
def list_dir(path: str = "") -> str:
hits = [p for p in REPO if p.startswith(path)]
return "\n".join(sorted(hits)) if hits else f"Nothing under {path!r}."
def grep(pattern: str) -> str:
hits = [f"{p}: {line}" for p, body in REPO.items()
for line in body.splitlines() if pattern.lower() in line.lower()]
return "\n".join(hits) if hits else f"No lines match {pattern!r}."
def read_file(path: str) -> str:
return REPO.get(path, f"No file at {path!r}.")
WORK_IMPL = {"list_dir": list_dir, "grep": grep, "read_file": read_file}
WORK_TOOLS = [
{"name": "list_dir", "description": "List repo paths under a prefix.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"}}, "required": []}},
{"name": "grep", "description": "Find lines matching a substring.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},
{"name": "read_file", "description": "Read one file in full, by path.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]}},
]
PLAN_TOOL = {
"name": "submit_plan",
"description": "Record a step-by-step plan before doing any work.",
"input_schema": {
"type": "object",
"properties": {
"steps": {"type": "array", "items": {"type": "string"},
"minItems": 1, "maxItems": 6},
},
"required": ["steps"],
},
}
def make_plan(question: str) -> list[str]:
resp = client.messages.create(
model=MODEL, max_tokens=1024,
tools=[PLAN_TOOL],
tool_choice={"type": "tool", "name": "submit_plan"}, # forced
messages=[{"role": "user", "content":
"Plan how to answer this question using list_dir, grep and "
"read_file tools. At most 6 concrete steps.\n\n"
"Question: " + question}],
)
block = next(b for b in resp.content if b.type == "tool_use")
return block.input["steps"]
def run_with_plan(question: str, max_iterations: int = 12) -> str:
plan = make_plan(question)
plan_text = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(plan))
print("Initial plan:\n" + plan_text + "\n")
task = (
f"Question: {question}\n\nYour plan:\n{plan_text}\n\n"
"Follow the plan, but treat it as a hypothesis. If an observation "
"contradicts a step, do NOT push on: call submit_plan again with a "
"revised plan, then continue."
)
messages = [{"role": "user", "content": task}]
revisions = 0
for _ in range(max_iterations):
resp = client.messages.create(
model=MODEL, max_tokens=1024,
tools=[PLAN_TOOL] + WORK_TOOLS, messages=messages,
)
if resp.stop_reason != "tool_use":
return next(b.text for b in resp.content if b.type == "text")
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type != "tool_use":
continue
if block.name == "submit_plan": # re-plan escape hatch
revisions += 1
revised = "\n".join(f"{i + 1}. {s}"
for i, s in enumerate(block.input["steps"]))
print(f"Re-plan #{revisions}:\n{revised}\n")
output = "Plan updated."
else:
output = WORK_IMPL[block.name](**block.input)
print(f"{block.name}({block.input}) -> {output[:60]!r}")
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
raise RuntimeError("max iterations exceeded")
print(run_with_plan("How does this service configure retries?"))revisions counter here). The key and client carry over from the ReAct cell — the only setup this cell adds is the fake repo with list_dir/grep/read_file; the loop is the same shape as lesson 1, just with submit_plan available mid-run. An agent that re-plans 5 times in 15 iterations is telling you the task or tools are underspecified.One more pattern from production harnesses worth naming: externalized plans. Instead of the plan living only as prose in context, serious agent products (coding agents are the canonical example) give the model a todo-list tool — create tasks, mark them in-progress/done. The plan becomes harness-visible state: the UI can render progress to the user, your code can detect stalled items, and — subtly the biggest win — marking a step done is an action the model takes, which anchors it against both forgetting steps and re-doing finished ones. It's the same idea as making re-planning a tool call: externalize the agent's intent so the harness can see it.
Whiteboard drills
- ▸ReAct = interleave Thought → Action → Observation; it fixed hallucination (reason-only) and impulsiveness (act-only).
- ▸Native tool calling is ReAct with typed plumbing:
tool_use= Action,tool_result= Observation. Explain the lineage; don't ship the regex. - ▸Prompting the model to state expectations before each call still improves tool choice — reasoning-before-acting is behavioral, not mechanical.
- ▸On frontier models the Thought leg is interleaved thinking — billed, replayed verbatim, and automatic; know all three layers of the lineage.
- ▸Upfront plans help long, coverage-style tasks; they hurt short tasks and anchor the model when wrong.
- ▸Make re-planning an explicit tool call so plan drift is visible in traces instead of silent — and damp it: cap revisions, require the contradicting observation, freeze completed steps.
- ▸Externalize plans as todo-list state when the harness or user needs to see progress — intent the harness can't see is intent it can't guard.