Agentic AI · The ReAct Loop
How a language model stops guessing and starts working: interleaving its own reasoning with real tool calls until it has enough to answer.
A language model has no scratchpad and no memory. ReAct (Reason + Act) gives it one by looping Thought → Action → Observation: the model writes down its reasoning, calls a tool, reads the raw result, and repeats — deciding for itself when it has enough to stop. The power is real; so is the danger, which is why every production loop needs strict limits on how far it can run.
Section 1 · hand-copy this
| Use ReAct | Do NOT |
|---|---|
| multi-tool reasoning | pure knowledge Q ("capital of France") |
| ambiguous states | single-step tool call |
| self-correction needed | deterministic lookup / FAQ |
| Strategy | What | Note |
|---|---|---|
| 1. messages modifier | trim_messages: keep sys + recent N, drop middle | simplest, predictable |
| 2. summarization node | cheap LLM writes structured summary at threshold | risk: info loss |
| 3. selective preservation | extract key facts → TypedDict; trim msgs hard | BEST; most effort |
A customer writes in: "My account C1002 shows past due — I already paid." A plain chatbot answers from thin air: "Your payment is being processed, thank you." It has no idea what C1002's balance actually is. It cannot look. It cannot check whether a refund was filed. It just pattern-matches a soothing sentence and hopes. The gap between that and a real support agent is the whole subject of this note: how do you get a model to go find out before it answers, and to keep going until it actually knows?
2.1A language model is a stateless next-word predictor — it has no place to "think" except the text it writes. Transformer-based models process a sequence and predict the next token, each token conditioned on the tokens before it in the context window. There is no hidden working-memory register, no notepad tucked away behind the weights. Once you accept that, one consequence follows hard: if you want the model to reason through a multi-step problem, the only mechanism available is to have it write its reasoning out as tokens, in the context, before it acts.
Ask a person for 17 × 23 + 45 and whether it's prime, in their head, and most will stumble. Hand them a pencil and paper and it's routine. The model is the person; the context window is the paper. Reasoning traces are what it scribbles down so the next step has something to build on.
The name says it: ReAct = Reason + Act. It was formalized in a 2022 paper by Yao et al. at Princeton and Google — "ReAct: Synergizing Reasoning and Acting in Language Models." The finding was that models which interleave reasoning traces with tool calls substantially outperform both pure-reasoning models (that think but can't check anything) and pure-tool-using models (that act without a plan) — on complex, multi-step tasks. That last clause matters, and we'll return to it.
When a reasoning model shows its step-by-step "thinking" and visibly self-corrects mid-answer, that is the ReAct pattern in the open. The self-correction isn't a bolted-on feature — it falls out of the loop: each thought gets to react to the last observation.
ReAct runs one repeating cycle — think, act, look at the result — and feeds each result back in as the starting point for the next thought. Three phases, in order, over and over:
The model writes a natural-language trace: what it understands so far, what's still missing, what it plans to do next. Pure text, added to context.
It picks a tool and the arguments to pass. The output is constrained to the tool's JSON schema (via bind-tools).
The tool runs; its raw, uninterpreted output enters the context as a tool message. The agent does not answer yet — it loops back to a new thought.
Notice the asymmetry in the Action phase. The format of the action is deterministic: the model must emit valid JSON matching the tool schema. But which tool it chooses is non-deterministic — that decision comes entirely from the reasoning it accumulated in the Thought phase. If the thinking is poor, the action will be poor. Reasoning quality upstream sets tool-choice quality downstream.
The Observation phase is deliberately dumb. The tool's output arrives raw and uninterpreted — the agent does not immediately react to it. It loops back into a fresh Thought, where it decides what the observation means, whether it's enough to answer, and what to do next. That extra step is a safeguard: it stops the agent from short-circuiting to a conclusion off a single data point.
Termination is decided by the agent, not by a fixed counter. When the model reasons "I now have enough," it emits a final answer with no tool call, and the loop ends. This is powerful and also exactly why loops run away — a confused agent may never decide it's done. Section 2.6 is entirely about bounding this.
ReAct is not "better." It's a tool you reach for when the problem genuinely needs multi-step reasoning over multiple tools — and a liability when it doesn't. Match the mechanism to the problem.
Wrapping a simple FAQ lookup in a ReAct loop adds latency, bloats the context, and raises cost — for no gain. The loop only pays off when the task is complex enough that step-by-step reasoning changes the outcome. Do not reach for ReAct by reflex.
A senior doctor facing an unusual presentation doesn't diagnose on sight. They think ("symptoms suggest X, but the patient's age and comorbidities point to Y"), order a test (an action), read the result (an observation), think again, order imaging, and only after several rounds commit to a diagnosis and plan. Thought = differential reasoning; Action = tests and imaging; Observation = the results. ReAct is that habit, in a machine.
The structural change ReAct forces is turning a one-way pipeline into a loop the agent can walk more than once. The classification graph built before this lesson was a DAG — a directed acyclic graph, a "no-going-back flowchart." Nodes wired by deterministic edges, compiled once, the path from input to output fixed at design time and immutable at runtime.
A ReAct graph is fundamentally different: it contains a cycle. The agent node routes to the tool node, and the tool node routes back to the agent node — which may route to the tool node again. In graph-theory terms you've converted an acyclic graph into a cyclic one: a state machine. LangGraph is built to support this safely, with recursion limits, state accumulation, and cycle detection.
The critical piece is the conditional edge — the "which-way sign" out of the agent. After every agent execution the graph asks one question: did the agent decide to call a tool, or did it produce a final answer? That question, and the routing it triggers, is what implements the ReAct pattern on top of an ordinary graph.
The router is a plain Python function that reads the current state and returns the name of the next node. It's the loop's decision point, and it has exactly two outcomes: return the tool node's name (continue the loop) or return a terminal route (end the graph). The implementation is small but load-bearing: inspect the last AI message. If it has a non-empty tool_calls attribute, the agent wants a tool → route to the tool node. If tool_calls is empty or absent, the agent produced a final answer → terminate.
def route_after_agent(state: SupportState) -> str:
last = state["messages"][-1]
# non-empty tool_calls → the agent wants to act
if getattr(last, "tool_calls", None):
return "tools" # continue the loop
return "respond" # no tool call → terminate
Because the decision about whether more information is needed is delegated entirely to the LLM, this router stays generic — it works across almost any agent configuration without change.
Resist writing if intent == "billing": call tool_A else tool_B in the router. That logic belongs in the system prompt (let the LLM choose the tool) or in the classifier node. A router that inspects state content becomes a maintenance nightmare and hard-codes decisions the model should make. Keep it structurally pure: it reads the shape of the last message, nothing more.
The messages field uses the add_messages reducer, which appends rather than overwrites. So across N iterations the message list grows into the full reasoning history: initial user query, then first thought (an AI message, possibly with a tool call), then first observation (a tool message), then second thought, second observation, and so on. When the agent node runs the second time it receives that whole trail as context. There is no special memory API — observations feed back into reasoning simply because they pile up in the context window.
The most challenging production failure of a ReAct agent isn't a crash — it's a slow rot as the accumulating context degrades the model's own performance. Every iteration appends to the context window, and because that same context is what the model reasons over, a large enough context makes each new thought worse. It gets severe only after many iterations — and by then the context is often too large to recover from without starting over.
The rough arithmetic (order-of-magnitude, per iteration): a thought runs 50–150 tokens, a tool call 30–80, and an observation anywhere from 50 to 500. Five iterations might carry the context from ~650 to ~3,000 tokens on top of the original query — trivial for a modern model. But a complex enterprise ticket (a billing dispute, an account reconciliation) can need 15–20 tool calls, pushing the context past 50,000 tokens. At that scale three problems emerge together.
Models attend strongly to the start and end of the context and under-attend the middle — exactly where the earlier reasoning and observations sit. The agent starts forgetting what it learned.
Attention cost is quadratic in context length. Context twice as long takes more than twice as long to process. Latency scales super-linearly.
Cost scales linearly with input tokens. Long loops with large observations run up an enormous bill in a high-volume system.
"Lost in the middle" is a named, well-documented LLM behavior worth being able to state cleanly: strong attention at the extremes of the context, weak attention in the center. It's the reason the fixes below all fight to keep the ends intact — the system prompt at the front and recent turns at the back — and treat the middle as expendable.
The simplest fix: hand the model a trimmed view of the history without touching the stored state. A transformation function is applied to the message list just before it's sent to the LLM. The state keeps its full history; only what the model sees shrinks. The common implementation is LangChain's trim_messages utility (also available via the messages_modifier parameter on the newer create_react_agent helper): it takes the full list and returns a subset that fits a token budget, using a rolling window.
from langchain_core.messages import trim_messages
def prep_messages(messages):
return trim_messages(
messages,
max_tokens=8000,
strategy="last", # rolling window: keep most recent
include_system=True, # system prompt always survives
token_counter=llm,
)
# state keeps everything; the LLM only sees the trimmed view
A naive trim ("keep the last K messages") will happily discard the original user query when K is small — and then the agent forgets what it was even asked. A production-grade rolling window always preserves three things: the system prompt, the original user message, and the most recent N tool interactions. Everything between those anchors is safe to drop.
More sophisticated: insert a dedicated node that compresses the history into a structured summary when it gets too long. The node checks whether the message count or estimated token count exceeds a threshold (say 20 messages ≈ 8,000 tokens). If so, it calls an LLM with a summarization prompt and replaces the bulk of the history with a structured summary containing four things:
| Field | Captures |
|---|---|
| original intent | what the user actually asked for |
| key facts | facts discovered through tool calls |
| actions taken | actions and their outcomes |
| unresolved | what remains open / next steps |
It preserves semantic content at the cost of extra LLM calls — more latency and money — but the trade-off is favorable when the alternative is context overflow or steep degradation. In practice the summarizer should run on a cheaper, lower-capability model than the main agent: summarizing is a far simpler task than multi-step reasoning.
The risk is information loss. A sloppy summary turns "BP 185/112 recorded on 2024-03-15" into "patient has elevated blood pressure" — and the next reader loses the clinically critical detail. The defense is a structured prompt that explicitly demands specific numbers, dates, IDs, amounts, and unresolved issues. In a support agent, dropping a transaction ID or an exact balance is the same failure.
The strongest approach: keep a separate structured store of extracted facts, so the raw message history can be trimmed hard without losing anything that matters. Rather than blindly trimming or summarizing, the agent maintains a structured knowledge base alongside the message history. Each observation is passed through, its key facts are extracted, and they land in a dedicated state field — for example a customer_facts dictionary inside a TypedDict state.
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class SupportState(TypedDict):
messages: Annotated[list, add_messages] # can be trimmed hard
customer_facts: dict # extracted, preserved
# e.g. {"balance": 240.00, "billing_status": "past_due",
# "customer_id": "C1002"}
Because the important facts from every observation are already extracted into queryable state, the message list can be trimmed aggressively without losing information. The agent is explicitly programmed to decide which fields in each tool response are critical facts to keep and which are noise to discard after the current step. It requires more implementation effort than trimming or summarizing, but it's the only one that guarantees precise factual accuracy across many iterations.
An expert doesn't memorize every document word-for-word. They extract the relevant facts and discard the raw text. Selective preservation is that habit encoded: pull the facts, keep them structured and queryable, let the raw prose go. The store can also live in a database keyed per user or per session rather than in-memory, for durability across threads.
Because the agent decides when to stop, a confused agent may never stop — so production loops need hard, external bounds. A stuck ReAct agent is genuinely expensive: at, say, 10,000 tokens and a second per iteration, a 100-iteration runaway burns minutes and millions of tokens. The loop typically gets stuck for one of three reasons:
A tool keeps returning an error; the agent reads the error as a hint to retry with slightly different args — which also fail — triggering yet another attempt.
The agent alternates between two conflicting interpretations — call tool A to check hypothesis 1, tool B to check hypothesis 2 — never committing to a conclusion.
Accumulated observations contain contradictory data, so the agent can't converge no matter how many times it loops.
Protection sits at two levels. At the infra level is a recursion limit set at graph compile time — a hard ceiling that protects against bugs and situations the application logic can't anticipate. At the application level is an iteration count that enables intelligent, user-friendly degradation: instead of a raw graph exception surfacing as a 500 error, the system generates a helpful message explaining what happened.
A common question: if you already have max-iterations, why also a recursion limit? Set the recursion limit low relative to max-iterations. The recursion limit counts graph state transitions and will crash the graph on a true runaway before the app ever reaches its iteration ceiling — catching bugs the application logic never foresaw. Max-iterations is your graceful, planned exit; the recursion limit is the fuse behind it.
The most precise loop signal is the agent calling the same tool with the same arguments more than once. If the tool returned valid data the first time, a second identical call returns identical data — pointless. If it returned an error, the same call returns the same error. Either way, an identical repeat is a loop indicator, and here iteration and recursion diverge: iteration counts any new node visit; recursion (in this sense) is the same tool + same args firing again and again. You don't want to wait for the iteration ceiling to catch that — you kill it directly.
The mechanism borrows a data-structures move. A Python set holds only unique elements. Store a fingerprint of every tool call — a hash of the tool name plus its sorted arguments — in a set on the state. Before executing a tool, a guard checks whether that fingerprint is already present. If it is, short-circuit: don't run the tool, return a synthetic tool message noting the call was already made and its result already observed.
import hashlib, json
def fingerprint(name: str, args: dict) -> str:
payload = name + json.dumps(args, sort_keys=True) # sorted args
return hashlib.sha256(payload.encode()).hexdigest()
def guarded_call(state, name, args):
fp = fingerprint(name, args)
if fp in state["seen_calls"]: # set of fingerprints
return {"role": "tool",
"content": "This call was already made; "
"result already observed."}
state["seen_calls"].add(fp)
return run_tool(name, args)
The entire repetitive-call defense is: hash(name + sorted args) → store in a set → check before running. That one move converts an otherwise infinite loop into a bounded execution, and at scale it's the difference between a handful of wasted calls and tens of thousands per day in a high-volume system.
The pattern comes from distributed systems and electrical engineering. The MCB in your home detects current over a safe threshold and physically breaks the circuit. A software circuit breaker detects a dependency failing at an unacceptable rate and stops forwarding requests — returning a cached response or a graceful error until it recovers, rather than hammering the failing dependency.
When the breaker fires, the agent must not return a bare error — it should hand off cleanly to a human. Whether the iteration count tripped or the repetitive-call detector fired, the production requirement is a graceful degradation that both tells the end user what happened and briefs the human who takes over. The escalation message has four fixed parts:
| # | Part | Serves |
|---|---|---|
| 1 | Acknowledge the original request | user knows they were heard |
| 2 | Summary of what tool calls gathered | briefs the human agent |
| 3 | Clear "automated resolution not possible" + why | sets expectation |
| 4 | Commitment to human follow-up + time frame | actionable next step |
The template must be hardcoded in the agent node, not generated by the LLM. The breaker fires precisely when the model is failing or looping — asking that same model to compose the apology invites another failure or a hallucinated commitment. Static text is the one thing you can trust at the moment everything else has gone wrong.
Human-in-the-loop (HITL) intervention naturally sits at two phases of the cycle. At the Action phase: require human approval before a consequential tool runs — for instance, an agent that decides a customer is eligible for a refund should pause for a person to approve processing that refund. At the Observation phase: let a human review the returned data before the agent's next thought acts on it.
2.8The lesson's running example is a production-grade CRM support agent in LangGraph. A classifier node sorts each incoming ticket into one of four categories — technical, billing, fraud, general — and a router sends it to the matching handler. The billing and technical handlers route into the agent node, the core ReAct reasoner, which has two tools bound to it:
Given a validated customer ID, returns name, billing status, subscription tier, last payment, outstanding balance, and recent transactions — from a mock CRM. Validates and normalizes the ID, looks it up, then filters out noisy internal fields (sales-rep code, geo tag, last-login IP) so only relevant data returns.
Keyword-matches a technical issue (API, login, SDK, 2FA, post-update) against a mock knowledge base and returns matching articles. On no match it returns fallback guidance rather than failing.
The CRM record holds far more than the agent should see — internal IDs, IP addresses, feature-flag cohorts. The tool returns only an explicit whitelist of relevant keys. Filtering at the tool boundary keeps observations small (helping with bloating) and keeps internal system details out of the model's context entirely.
Two guardrail habits from the tool design are worth carrying: every @tool-decorated function gets a precise docstring stating what it does, when to call it, the input format, and the output format — this is what the model reads to choose tools. And the customer-ID tool never guesses or fabricates an ID: if it's missing from the message, the agent must ask the user. [+] That "never fabricate an identifier" rule generalizes to any tool keyed on a user-supplied value.
2.9Transformer models are stateless sequence processors with no working-memory register; each token is predicted from the tokens already in context. There's no place to "think" that isn't the visible output. So the only way to make the model reason across steps is to have it write its reasoning as tokens in the context, which later steps can then build on. That written trail is the scratchpad.
Thought: the model writes a reasoning trace — what it knows, the gap, the plan. Action: it selects a tool and emits arguments constrained to that tool's JSON schema. Observation: the tool runs and its raw output enters the context as a tool message. The agent then loops back to a new thought to interpret that observation and decide whether to act again or answer.
The agent decides, not a counter. The router inspects the last AI message: a non-empty tool_calls attribute means it wants to act, so route to the tool node; an empty or absent one means it produced a final answer, so terminate. Because that judgment is delegated to the LLM, the router stays generic across agents.
The router's only job is to inspect the shape of the last message — did the agent request a tool or not. Encoding rules like "if billing, call tool A" hard-codes decisions the LLM (via its system prompt) or the classifier node should own, and it becomes a maintenance nightmare as tools and intents grow. Keep the router structurally pure and let the model choose tools.
Every iteration appends thoughts, tool calls, and observations to the context the model reasons over. Past a point it causes three problems at once: lost-in-the-middle (the model under-attends older reasoning), O(N²) latency growth, and linearly rising cost. It's worse than a crash because it's gradual — performance quietly degrades, and by the time it's obvious the context is often too large to recover without restarting.
Messages modifier (trim) hands the model a trimmed view — simplest and most predictable, but a naive window can drop the original query. Summarization compresses history via a cheap model into a structured summary — preserves meaning but risks losing exact numbers if the prompt isn't structured. Selective preservation extracts key facts into a TypedDict so raw messages can be trimmed hard — the best fidelity, and the most implementation effort.
They protect at different levels. Max-iterations is the application-level, graceful exit that produces a helpful escalation message. The recursion limit is an infra-level fuse set at compile time and kept low relative to max-iterations, so it crashes a true runaway before the app logic ever reaches its ceiling — catching bugs and edge cases the application couldn't anticipate.
Compute a fingerprint — a hash of the tool name plus its sorted arguments — and store each in a set on the state. Before running a tool, check whether its fingerprint is already present; if so, short-circuit and return a synthetic message saying the call was already made and its result observed. Since an identical call yields an identical result (or identical error), repeating it is pointless, and this turns a would-be infinite loop into a bounded run.
The circuit breaker fires exactly when the model is failing or stuck in a loop. Asking that same model to compose the hand-off risks another failure or a hallucinated promise (like an invented resolution time). A hardcoded four-part template — acknowledge, summarize findings, state that automation failed and why, commit to human follow-up — is the one reliable output at the moment everything else has broken.
Terminal vs. non-terminal is decided by edges, fixed at design/compile time — not by the node itself. A node with an edge to END is terminal; a node whose edges point to other nodes is not. A conditional edge lets the same node be terminal on one path and continue on another: the agent node routes to END when it produces a final answer, or back to the tool node when it requests a tool.
Build it · Chapter 12 · Atlas
Stop guessing, start working. The agent interleaves a Thought with an Action (a tool call) and an Observation (the result), looping until it can Finish. It walks Microsoft → Alpha → Nimbus → Elena across the graph — and every loop is bounded by a hard step limit, because a cyclic graph will otherwise run forever.
policy is a rule-based stand-in for the model's reasoning, so the walk is deterministic and readable. Swap it for an LLM that emits Action: tool(arg) and the loop mechanics are identical.atlas/react.py — thought → action → observation, with a step limit:
"""Chapter 12 — the ReAct loop: reason, act with a tool, observe, repeat.
The model doesn't answer in one shot; it interleaves a Thought with an Action
(a tool call) and an Observation (the result), looping until it can Finish.
Every real loop needs a hard step limit — a cyclic graph will happily run
forever otherwise. The `policy` here is a rule-based stand-in for the model.
"""
from atlas.extract import build_graph, CORPUS, resolve
GRAPH = build_graph(CORPUS)
# ---- tools the agent may call --------------------------------------------
def find(text):
"""Locate the entity a question names."""
return resolve(text)
def neighbors(entity):
"""Every entity one hop from here, with its label."""
out = []
for s, r, o in GRAPH.neighbors(entity):
other = o if s == entity else s
out.append((r, other, GRAPH.nodes[other]["label"]))
return out
TOOLS = {"find": find, "neighbors": neighbors}
def policy(question, frontier, visited):
"""Stands in for the model's Thought: what to do given what we've seen."""
obs = neighbors(frontier)
person = next((o for r, o, lab in obs if lab == "Person"), None)
if person:
return "finish", person, obs
nxt = next((o for r, o, lab in obs if lab == "Company" and o not in visited),
None)
return ("hop", nxt, obs) if nxt else ("stop", None, obs)
def react(question, max_steps=6, trace=False):
frontier = find(question) # Action: find the entry entity
visited = {frontier}
for step in range(max_steps): # the execution bound — never unbounded
action, target, obs = policy(question, frontier, visited)
if trace:
print(f" Thought: at {frontier} Action: neighbors({frontier})")
print(f" Observation: {obs}")
if action == "finish":
return f"{target} leads {frontier}."
if action == "stop":
return "No answer found in the graph."
visited.add(target)
frontier = target
return f"Stopped after {max_steps} steps (limit reached)."
if __name__ == "__main__":
q = "Who leads the company that competes with the startup Microsoft acquired?"
print("Q:", q, "\n")
print("A:", react(q, trace=True))
Run it — you'll see:
Q: Who leads the company that competes with the startup Microsoft acquired?
Thought: at Microsoft Action: neighbors(Microsoft)
Observation: [('acquired', 'Alpha', 'Company')]
Thought: at Alpha Action: neighbors(Alpha)
Observation: [('acquired', 'Microsoft', 'Company'), ('competes_with', 'Nimbus', 'Company')]
Thought: at Nimbus Action: neighbors(Nimbus)
Observation: [('competes_with', 'Alpha', 'Company'), ('works_for', 'Elena', 'Person')]
A: Elena leads Nimbus.
This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.