Module 2: The Agent Loop · Lesson 2 of 5 · 35 min

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.

Thought:I need current pricing — my training data is stale.Action:web_search("Claude API pricing 2026")Observation:Result: pricing page → $/MTok input, output…Thought:I have what I need. Compose the answer.Answer:Grounded response with the fresh numbers.
ReAct interleaves verbalized reasoning (Thought) with tool use (Action) and its result (Observation), looping until a finish action.
1/5
the original technique: ReAct as pure prompting (know it, don't ship it)
# 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?"))
Two load-bearing tricks: 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.
Predict the output
A teammate deletes the 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

StrategyHow it worksWins whenFails when
Plan-as-you-go (pure ReAct)No explicit plan; each iteration decides the next step from accumulated contextShort tasks (≤ ~5 steps); environments where each result reshapes the taskLong tasks — the agent wanders, repeats work, forgets the goal
Upfront planFirst call produces a step list; the loop executes with the plan pinned in contextMulti-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-planUpfront plan, plus an explicit trigger to revise when observations contradict itLong tasks in uncertain environments — the default for serious agentsTrigger 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.

plan-first agent with an explicit re-plan escape hatch
# 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?"))
Three deliberate choices: the plan is produced by a forced structured call (Module 1's tool-choice trick), so you always get a parseable list; the plan is framed as "a hypothesis", which measurably lowers the model's tendency to defend it; and re-planning is a tool call — so it shows up in your trace log and you can count revisions per run (the 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.

When planning hurts
A plan step adds a full LLM call of cost and latency, plus permanent context weight. For a task the model can do in 2–3 tool calls, planning is pure overhead — and a wrong plan is worse than no plan, because it anchors the model. Rule of thumb: add upfront planning when tasks routinely exceed ~5 tool calls or need coverage guarantees; skip it below that.
What the interview actually probes
"Design an agent that plans and executes a multi-step task" is a staple of senior agent-design rounds — and the junior/senior split lives entirely in the follow-ups. Reciting "ReAct = Thought/Action/Observation" is table stakes; the senior tells are naming plan drift unprompted, making re-planning an explicit tool call so it surfaces in traces, knowing when not to plan (short tasks, where a wrong plan anchors the model), and explaining why native tool calling made the Observation structurally impossible to forge. Volunteer the costs and failure modes, not just the mechanism — that is the line interviewers listen for.

Whiteboard drills

Check yourself
Drill: Traces show your research agent wandering on long tasks — re-searching topics it already covered, forgetting to check one of the three sources the task named. Diagnose and fix, out loud.
Check yourself
Drill: Design the re-plan trigger. Too eager and the agent thrashes, too lazy and you get plan drift — what do you actually implement?
Key takeaways
  • 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.