Agentic AI · The ReAct Loop

The ReAct Architecture

How a language model stops guessing and starts working: interleaving its own reasoning with real tool calls until it has enough to answer.

Updated July 2026 · Read ~22 min · LangGraph · CRM support agent


// the big idea

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

Notebook Summary

Core paradigm

  • ReAct = Reason + Act. Formalized 2022, Yao et al. (Princeton + Google): "Synergizing Reasoning and Acting in LLMs."
  • Why needed → transformers are stateless sequence processors; no working-memory register.
  • only way to make model "think" = make it write thoughts as tokens before acting.
  • Scratchpad → reasoning traces = cumulative context. Every thought conditions the next action.
  • Result → interleaved reasoning + tools beats pure-reasoning OR pure-tool-use — on complex multi-step tasks only.
doodle: pencil + notepad next to a robot head — "LLM needs paper to think."

The core loop

  • Cycle → Thought → Action → Observation → (repeat).
  • Thought = reasoning trace: what I know, the gap, the plan.
  • Action = pick tool + args (JSON schema-constrained).
  • Observation = raw, uninterpreted tool output → back into context.
  • Terminates → agent decides, NOT a fixed counter. Empty tool_calls = done.
  • Determinism → tool output shape = deterministic; tool selection = non-deterministic (LLM's call).
doodle: circle of 3 arrows labelled T→A→O, one exit arrow "answer".

When to use ReAct

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
  • ⚠ReAct on a simple task = over-engineering: adds latency, bloats context, raises cost, zero benefit.

State & graph shape

  • Old graph = DAG: fixed, immutable path decided at design time.
  • ReAct graph = cyclic state machine: agent ⇄ tool node.
  • Key addition → conditional edge = router: "tool call, or final answer?"
  • Router rule → inspect last AI msg. non-empty tool_calls → tool node; empty → END.
  • ⚠NEVER put business logic in the router. Router inspects shape, not content. Tool choice lives in the system prompt.
  • messages → add_messages reducer appends (never overwrites). Grows: query → thought → obs → thought → obs …

Context window bloating

  • Failure mode → not a crash; gradual degradation, severe after many iterations.
  • Growth/iter → thought 50–150 tok · tool call 30–80 · obs 50–500.
  • 5 iters ≈ 650→3000 tok; complex ticket 15–20 calls → 50k+ tok.
  • 3 problems:
  • 1. lost-in-the-middle — attends to start+end, under-attends middle (= old reasoning).
  • 2. latency — attention is O(N²); 2× context = >2× time.
  • 3. cost — scales linearly with input tokens.

Fix bloating — 3 strategies

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
  • ⭐Always preserve: system prompt + original user message + recent N tool interactions. Trim the middle.
  • 💡Summarizer must use a cheaper model & a structured prompt (intent · key facts · actions · unresolved) — plain summaries drop numbers/IDs.
doodle: long tape of msgs, scissors cut the middle, jar labelled "facts" catches the key bits.

Infinite-loop prevention

  • 3 stuck causes → tool-failure cascade · oscillating reasoning (A↔B) · context corruption (contradictory obs).
  • 2 levels:
  • infra = recursion limit @ compile time (catches bugs; hard crash).
  • app = iteration count → graceful degradation.
  • ⭐Set recursion limit LOW relative to max-iterations so infra catches runaways the app logic can't foresee.
  • Repetitive-call detect → fingerprint = hash(tool name + sorted args), stored in a set. Seen before → short-circuit with synthetic message.
  • ⭐iteration ≠ recursion: iteration = any new node; recursion = same tool + same args repeated. Kill the second with a set.
  • Circuit breaker → analogy: MCB / fuse trips over threshold; returns graceful error not 500.
doodle: fuse box with a tripped switch; wire loop with a scissors labelled "set".

Graceful escalation → human

  • Template (4 parts) → 1. ack original request · 2. summary of what tools found · 3. clear "auto resolution not possible" + why · 4. commitment to human follow-up + time frame.
  • ⭐HARDCODE the template in the agent node. Don't ask the LLM to write it — it may fail/hallucinate exactly when the breaker fired.
  • HITL points → action phase (approve before tool runs, e.g. refund) · after observation (review data before next thought).
Section 2

Detailed Study Notes

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.1

Why a model needs a scratchpad

A 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.

Analogy · recall

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.

💡 Tip · you've already seen 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.

2.2

The core loop: Thought, Action, 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:

Thought

Reason

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.

Action

Act

It picks a tool and the arguments to pass. The output is constrained to the tool's JSON schema (via bind-tools).

Observation

Look

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.

user query Thought Action Observation tool runs feeds next thought final answer (no tool call) enough info?
fig 2.2 — the loop cycles until a thought produces no tool call; that empty call is the exit.

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.

⚠ Pitfall · what stops the loop

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.

2.3

When ReAct earns its keep — and when it doesn't

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.

Pure LLM / single tool

  • Answer already lives in the model's training knowledge ("capital of France").
  • One deterministic lookup or calculation (a calculator agent).
  • Fixed FAQ retrieval.

ReAct loop

  • Multiple tools available; the agent must choose among them.
  • Ambiguous state that needs investigation.
  • Self-correction — revise the plan as evidence arrives.
⚠ Pitfall · over-engineering

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.

Analogy · the diagnosing physician

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.

2.4

From a straight line to a cycle

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.

DAG · fixed path classify handler END ReAct · cycle agent tool tool call observation back END no tool call → respond
fig 2.4 — the conditional edge out of the agent is what makes the graph loop instead of running once.

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: decide continue vs. terminate

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.

router — inspects shape, not content
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.

⚠ Pitfall · never put business logic in the router

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.

State accumulates every iteration

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.

user query→ thought (AI + tool_call)→ observation (tool msg)→ thought→ observation→ …
2.5

The quiet killer: context window bloating

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.

1 · quality

Lost in the middle

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.

2 · latency

O(N²) attention

Attention cost is quadratic in context length. Context twice as long takes more than twice as long to process. Latency scales super-linearly.

3 · cost

Linear billing

Cost scales linearly with input tokens. Long loops with large observations run up an enormous bill in a high-volume system.

💡 Tip · lost in the middle

"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.

Strategy 1 — messages modifier (trim)

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.

trim to a token budget, keep the ends
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
⭐ Trick · what a production trim must keep

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.

Strategy 2 — summarization node

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.

⚠ Pitfall · summaries lose the numbers

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.

Strategy 3 — selective context preservation (best)

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.

facts in a TypedDict, not buried in raw messages
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.

raw message tape (trimmed hard) obs obs obs drop drop extract key facts customer_facts (TypedDict) balance · billing_status · id · tier preserved even as messages are trimmed
fig 2.5 — facts survive in the TypedDict; the raw observations they came from can be thrown away.
Analogy · how experts read

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.

2.6

Stopping the runaway: infinite-loop prevention

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:

cause 1

Tool-failure cascade

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.

cause 2

Oscillating reasoning

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.

cause 3

Context corruption

Accumulated observations contain contradictory data, so the agent can't converge no matter how many times it loops.

Two levels of protection

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.

⭐ Trick · recursion limit vs. max-iterations, and why both

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.

agent node iter_count += 1 iter > max? yes graceful escalation hardcoded template END no tool calls? tool node → agent
fig 2.6 — two exits: graceful escalation at the iteration ceiling, or normal termination when the query is resolved.

Detecting repetitive calls with a set

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.

fingerprint guard — turn a loop into a bounded run
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)
⭐ Trick · the set is the whole trick

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.

Analogy · the miniature circuit breaker

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.

2.7

When the agent gives up well: graceful escalation

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
⚠ Pitfall · hardcode the escalation template

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.

Where a human fits in the loop

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.8

Where this shows up: the CRM support agent

The 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:

get_customer_details

CRM lookup

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.

search_knowledge_base

Troubleshooting lookup

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.

⭐ Trick · filter noise inside the tool

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.9

Interview & practice questions

Why does an LLM need a "scratchpad" at all — isn't reasoning built into the model?

Transformer 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.

Walk me through one iteration of the ReAct loop.

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.

How does the loop know when to stop?

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.

Why should business logic never live in the router?

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.

What is context window bloating, and why is it worse than a crash?

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.

Compare the three context-management strategies.

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.

If you already cap iterations, why also set a recursion limit?

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.

How does the set-based fingerprint stop repetitive tool calls?

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.

Why hardcode the escalation message instead of letting the LLM write it?

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.

Can a node be both a terminal node and a non-terminal node?

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.


2.10

Key takeaways

  1. ReAct exists because models can't think without writing. Reasoning traces are the scratchpad; the loop is Thought → Action → Observation, repeated.
  2. The agent decides when to stop — an empty tool call is the exit. That autonomy is the power and the risk.
  3. Only reach for ReAct on complex, multi-tool, self-correcting tasks. On simple lookups it's pure overhead.
  4. The conditional edge (router) turns a DAG into a cycle — and it must inspect message shape only, never business logic.
  5. Context bloating degrades quality, latency (O(N²)), and cost. Fix it with trimming, summarization, or — best — selective fact preservation in a TypedDict.
  6. Bound every loop twice: an infra recursion limit (low) plus an app iteration count (graceful), with set-based fingerprinting to kill repeated calls.
  7. Fail gracefully with a hardcoded escalation template — never let the failing model write its own hand-off.
reason + act scratchpad thought→action→observation agent-decided termination conditional edge = router shape not content add_messages appends DAG → cyclic state machine lost in the middle O(N²) attention trim_messages summarization node selective preservation TypedDict facts recursion limit (infra) iteration count (app) fingerprint set circuit breaker graceful escalation hardcoded template HITL: action + observation

Build it · Chapter 12 · Atlas

Build it — the ReAct loop

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.

File atlas/react.pyRun python3 -m atlas.reactDeps Python 3 · stdlib only
The 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 ↗.