Skip to content
Chapter 2 of 8

The ReAct loop: reason, act, observe

One tool call answers a one-step question. Real tasks have steps, and the pattern that handles them is ReAct: reason, act, observe, repeat. In a loop the agent reasons about what it needs next, acts by calling a tool, observes the result, writes it to a scratchpad, and goes again until it can answer. Two things keep the loop honest: a stop condition so it ends when the goal is met, and a step cap so it can never run forever. In this lab the agent answers a two-step question that no single call can solve, the temperature in Paris plus ten. It looks up the weather, observes the number, calls the calculator on that number, and only then answers, and you prove the loop reached the right result inside its cap.

The lab: read it, then run it

labs/agents-mcp/am2-react-loop.py
#!/usr/bin/env python3
"""
LAB AM2: The ReAct loop. Reason, act, observe, repeat.

One tool call is not enough for a real task, because real tasks have steps. The
ReAct pattern is the engine of every agent: in a loop, the agent REASONS about
what it needs next, ACTS by calling a tool, OBSERVES the result, and repeats
until it can answer. It keeps a scratchpad of what it has learned so far, and it
STOPS when the goal is met or a step cap is hit. In this lab the agent answers a
two-step question ("the temperature in Paris, plus 10") that no single tool call
can solve: it looks up the weather, observes the number, then calls the
calculator, and only then answers, proving the loop reached the right result.

Run: python3 modules/academy-content/labs/agents-mcp/am2-react-loop.py
"""
import sys, os
_cands = [os.path.join(os.path.dirname(__file__), "..") if "__file__" in globals() else None,
          os.path.join(os.getcwd(), "..", "labs"), os.path.join(os.getcwd(), "labs")]
for _c in _cands:
    if _c and os.path.exists(os.path.join(_c, "academy_llm.py")):
        sys.path.insert(0, os.path.abspath(_c)); break
from academy_llm import tool_route
import re

# ── Tools: a weather dict-lookup and a calculator. ──────────────────────────────
_WEATHER = {"paris": ("sunny", 22), "tokyo": ("clear", 30), "london": ("rainy", 15)}

def weather(city):
    key = city.strip().lower()
    for name, (cond, temp) in _WEATHER.items():
        if name in key:
            return temp
    raise ValueError("no weather for %r" % city)

def calculator(expr):
    m = re.search(r"(-?\d+(?:\.\d+)?)\s*([+\-*/x])\s*(-?\d+(?:\.\d+)?)", expr)
    if not m:
        raise ValueError("cannot parse %r" % expr)
    a, op, b = float(m.group(1)), m.group(2), float(m.group(3))
    r = {"+": a + b, "-": a - b, "*": a * b, "x": a * b, "/": a / b if b else float("nan")}[op]
    return int(r) if r == int(r) else r

def dispatch(tool, arg):
    return {"weather": weather, "calculator": calculator}[tool](arg)

TOOLS = ["weather", "calculator", "search"]
GOAL = "the temperature in Paris plus 10"
TRUTH = 32
MAX_STEPS = 5

# The agent's PLAN: the ordered sub-goals it will reason through. A real agent
# derives these as it goes; here they are explicit so the loop is easy to read.
plan = [
    "look up the weather temperature in Paris",   # -> weather tool
    "compute {temp} + 10",                          # -> calculator, filled from observation
]

scratchpad = []   # OBSERVE store: what the agent has learned this run
answer = None
steps = 0

print(f"GOAL: {GOAL}  (truth = {TRUTH})")
print("")
for i, subgoal in enumerate(plan):
    if steps >= MAX_STEPS:            # the stop condition: never run away
        break
    steps += 1
    # Fill the sub-goal from what we already observed (chain step 2 onto step 1).
    thought = subgoal.format(temp=scratchpad[-1]["observation"]) if scratchpad else subgoal
    tool = tool_route(thought, TOOLS)                       # REASON: pick a tool
    observation = dispatch(tool, thought)                  # ACT + OBSERVE
    scratchpad.append({"thought": thought, "tool": tool, "observation": observation})
    print(f"STEP {steps}: reason -> {thought!r}")
    print(f"         act    -> {tool}()")
    print(f"         observe-> {observation!r}")
    answer = observation   # the last observation is the running answer

print("")
print(f"final answer : {answer!r}")
print(f"reached in   : {steps} steps (cap {MAX_STEPS})")

ok = (answer == TRUTH) and (steps <= MAX_STEPS)
print("")
print(f"REACT LOOP REACHED THE CORRECT ANSWER: {'YES' if ok else 'NO'}")
if not ok:
    sys.exit(1)
print("Reason, act, observe, repeat, stop. Next: designing the tools themselves.")
Runnable lab
am2-react-loop.py

Run a reason-act-observe loop over two tools and prove it reaches the correct multi-step answer within its step cap.

Proves: REACT LOOP REACHED THE CORRECT ANSWER: YES

Open the notebook

Runs in your browser via Pyodide. First run loads the runtime once; no install, no server.

Watch the loop work. Step one reasoned that it needed the Paris temperature and called the weather tool, observing 22. Step two chained that observation into the calculator, observing 32, which was the answer. No single tool call could have gotten there. The scratchpad is what let step two use what step one learned, and the step cap is what guarantees the loop always ends. In a real agent the reasoning and the tool-call parsing come from the model rather than a fixed plan, but the shape is identical. Next you design the tools this loop calls, because a tool is only as good as the description the model routes on.
Check your understanding
  1. 1. What are the three repeating phases of the ReAct loop?

  2. 2. Why did the two-step question in the lab need a loop?

  3. 3. What is the point of a step cap in an agent loop?