Study Notes
Noteapplied

The ReAct Agent — Production Loop Safety

A second pass over ReAct through the production failure modes — context bloat, duplicate tool calls, impure routers, unbounded cycles, escalation thresholds, and the circuit breakers that turn an elegant reasoning loop into a shippable agent.

35 min read·verified Content review 2026·reviewed 2026-09-01

Assumes you know

After this you should be able to

  • Diagnose context bloat and repeated-call failures in a ReAct loop
  • Apply iteration, duplicate-call, and escalation guards
ReAct SafetyCircuit BreakersEscalation
Artificial Intelligence · Agent Architecture

How a language model with no memory of its own learns to reason, reach for tools, and self-correct across many steps, and the engineering it takes to keep that loop from bloating or spinning forever.

Updated September 2026 · Read ~18 min · LangGraph · ReAct (Yao et al., 2022)


// the big idea

React = Reason + Act. The model writes its own reasoning to a scratchpad, then loops Thought → Action → Observation until it has enough to answer. That loop is what beats a plain LLM on hard, multi-step tasks, and the entire craft of building one is keeping the loop bounded: taming the context it accumulates, and stopping it before it runs away.


Section 1

Notebook Summary#

Hand-copy this. Everything here is derived from the detailed notes below.

WHAT / WHY#

  • ReAct = Reason + Act. Paper: Yao et al., 2022 (Princeton + Google).
  • Interleaves reasoning traces w/ tool calls → beats pure-reasoning AND pure-tool models on complex tasks.
  • Scratchpad: transformers are stateless, no working memory → the ONLY way to "think" is to write tokens before acting.
  • Simple task → React = over-engineering. Use only when multi-step + tools + self-correction.

THE LOOP#

  • ◯ Thought → reasoning trace: what I know, the gap, the plan.
  • ◯ Action → pick tool + args (constrained by schema). Tool choice = non-deterministic (LLM); output shape = deterministic (JSON).
  • ◯ Observation → raw, unfiltered tool output back into context. Don't answer yet → new Thought.
  • Repeat. Termination: LLM decides (last msg has no tool_calls) → respond.
  • ✎ doodle: 3 circles in a ring, arrows clockwise, labels T / A / O, human icon tapping between A and O.

GRAPH SHAPE#

  • DAG (old) → fixed path, set at design time, no cycles.
  • React graph → cyclic: agent ⇄ tool. Conditional edge = the decision.
  • CRM agent nodes: classifier → route → agent ⇄ tool → respond → END.
  • ✎ doodle: box "agent" and box "tool" with a loop arrow between; diamond exits to "respond".
  • ⚠ Router = PURE. Inspect last msg only (tool_calls? y/n). NEVER put business logic (if category==...) in it → belongs in system prompt / classifier.

STATE#

  • messages: Annotated[list, add_messages] → reducer APPENDS, never overwrites.
  • Ledger grows: query → AI(thought+tool_call) → ToolMsg(obs) → AI → ToolMsg → …
  • Observations feed back in via plain context accumulation. No special API.

CONTEXT BLOAT — 3 symptoms#

SymptomWhat
Lost in middleattends to start+end, under-attends middle (= your earlier steps)
Latencyattention is O(N²) → 2× context ≫ 2× time
Costscales ~linearly w/ input tokens; 15–20 tool calls realistic ceiling

3 FIXES (worst → best)#

StrategyRule
1 Trim (messages modifier)trim_messages, rolling window. Keep system + orig user + last N. Simple, lossy.
2 Summarize nodecheaper model → structured summary (intent, key facts, actions, unresolved).
3 Selective preserve ★extract key facts to TypedDict (customer_facts); trim messages hard. Best.
  • ⚠ Summary must keep NUMBERS/IDs/dates. "elevated BP" ≠ "BP 185/112 on 24-03-15".

INFINITE LOOPS — causes + guards#

  • Causes: (1) tool-failure cascade (2) oscillating reasoning (3) context corruption / contradictory obs.
  • Recursion limit — infra, set at compile. Catches bugs; set LOW vs iteration cap.
  • Iteration count — app level → graceful degradation, not a 500.
  • ★ Fingerprint = hash(tool_name + sorted(args)), stored in a set. Same call again → short-circuit. Same args = same result/error → no point.
  • Circuit breaker (MCB analogy): trip on failure rate → return cached / graceful error.

ESCALATION (hardcoded, 4 parts)#

  • 1. acknowledge original request · 2. what was gathered · 3. couldn't auto-resolve (+why) · 4. human follow-up + SLA.
  • ⚠ Hardcode it — do NOT make another LLM call at failure (it may fail/hallucinate too).

Section 2

Detailed Study Notes#

A customer writes in: "My account C1002 shows past due, but I paid last week." A classifier can tag this ticket billing and promise a reply in two hours. It cannot actually look at the account. To answer, something has to pause, go fetch the real record, read it, maybe check a second source, and only then speak. The moment you give a language model that ability, you stop building a labeler and start building an agent, and you inherit a new problem: knowing when to stop.

2.1From a classifier to an agent that acts#

Yesterday's build could only sort a ticket into one of four buckets (technical, billing, fraud, general). That is a one-shot decision. The upgrade in this lesson is a real tool-execution layer: the model can now call functions that read a mock CRM database and a troubleshooting knowledge base, then ground its answer in whatever comes back. The plain-English version: instead of a receptionist who files your complaint, you now have a support rep who can actually open your file while you wait.

Two things make that work. First, the functions are exposed as tools the model is allowed to call. Second, the graph is wired so the model can call a tool, look at the result, and decide whether to call another one, which is the loop the rest of this note is about.

2.2Anatomy of the CRM support agent#

The agent is a small graph of nodes that pass a shared state between them. The state is a typed dictionary; the key field is the message list, and it uses a reducer so new messages are appended rather than overwriting the history.

state schema
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages

class SupportState(TypedDict):
    raw_input: str
    category: str                                # technical | billing | fraud | general
    messages: Annotated[list, add_messages]      # add_messages APPENDS, never overwrites
    customer_facts: dict                         # for selective context preservation (2.10)
    iteration: int                               # loop guard (2.10)

A tool is just a decorated function with a docstring. The docstring is not decoration, it is the contract the model reads to decide when to call the tool, what arguments to pass, and what shape comes back. Notice the three layers inside: validate the argument, look it up, then filter out the noisy fields so only the relevant data returns.

tool: get_customer_details
from langchain_core.tools import tool

@tool
def get_customer_details(customer_id: str) -> dict:
    """Fetch billing status, tier, and recent transactions for a customer.
    Call before answering ANY billing question. IDs look like 'C1002'.
    Never guess an ID, ask the user if it is missing."""
    cid = customer_id.strip().upper()                  # layer 1: validate + normalize
    if not cid.startswith("C"):
        return {"error": "invalid customer id format"}
    raw = MOCK_CRM.get(cid)                             # layer 2: database lookup
    if raw is None:
        return {"error": "customer not found"}
    keep = {"name", "billing_status", "tier", "outstanding_balance", "recent_transactions"}
    return {k: v for k, v in raw.items() if k in keep} # layer 3: drop noisy fields

A second tool, search_knowledge_base, does keyword lookups over troubleshooting articles (no semantic/vector search, just exact keyword matches on a dict). Both tools are bound to the model with llm.bind_tools([...]), which is how the model knows they exist. The full graph then flows like this:

START classifier route fraud / general → END agent bill/tech tool loop respond no calls END
Fig. 2.2 — classifier routes; billing/technical enter the agent⇄tool cycle; the loop exits to respond only when the model stops asking for tools.

2.3ReAct = Reason + Act, and the scratchpad#

ReAct is a way of running an LLM where it writes its reasoning as text, then takes an action, then reads the result, and repeats. Formalized in a 2022 paper by Yao and colleagues at Princeton and Google, the finding was simple: a model that interleaves reasoning traces with tool calls beats both a pure-reasoning model and a pure-tool-using model, on tasks that genuinely need several steps.

Why write the reasoning down at all? Because a transformer is stateless. It processes a sequence and predicts the next token; each token is conditioned on the tokens before it in the context window. There is no separate working-memory register, no private notepad. So the only mechanism the model has to "think" is to emit its thoughts as tokens into the context before it acts. Those tokens become the scratchpad.

Analogy · recall

Asked to compute 17 × 23 + 45 and say whether it's prime, most people can't do it in their head, but hand them a scrap of paper and it's easy. The paper doesn't make them smarter; it lets them externalize intermediate state. The scratchpad is that scrap of paper for a model that otherwise forgets everything the instant it stops writing.

2.4The core loop: Thought → Action → Observation#

Every ReAct agent cycles through three phases until it decides it's done.

Thought#

A reasoning trace in natural language: what I understand, what I've gathered, the gap that's left, what I'll do next.

Action#

Choose a tool and its arguments. Which tool is a non-deterministic call by the model; the output shape is deterministic JSON fixed by the tool schema.

Observation#

The raw, unfiltered tool output enters the context as a tool message. The agent does not answer yet, it starts a new Thought about what the observation means.

Thought reason Action tool + args Observe raw output human-in-loop
Fig. 2.4 — the cycle repeats; a human can approve at the Action step or review at the Observation step.

The observation phase matters more than it looks. By forcing the raw result back through a fresh Thought before answering, the loop stops the agent from short-circuiting, jumping to a conclusion off a single data point. Termination is decided by the model itself, not a fixed counter: when the last message carries no tool calls, the agent has produced a final answer.

2.5When to reach for React (and when not to)#

React is not automatically better. For a task the model can answer from its own knowledge, or with a single lookup, the loop just adds latency, context, and cost.

Plain LLM / single tool call#

use when
  • Answer lives in the model ("capital of France")
  • One deterministic lookup or calculation
  • No branching, no self-correction needed

React loop#

use when
  • Multiple tools available, choice depends on findings
  • 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 is a classic mistake. It adds latency, bloats the context, and multiplies cost with zero benefit. Match the machinery to the task, most tickets don't need a loop at all.

Analogy · the diagnosing physician

A senior doctor facing an unusual case doesn't blurt a diagnosis. They think ("symptoms suggest X, but the patient's age argues for Y"), act (order a lab test), observe the result, think again, order imaging, and only after several rounds commit to a treatment plan. Thought = the differential reasoning; Action = the tests ordered; Observation = the results read. That's a React loop in a white coat.

2.6From a DAG to a cycle: the conditional edge#

The classifier graph you started with was a DAG, a directed acyclic graph: a fixed sequence of nodes joined by deterministic edges, with a path decided at design time and no way to revisit a node. A React graph is fundamentally different: it contains a cycle. The agent node can route to the tool node, which routes back to the agent node, as many times as needed. In graph terms you've turned an acyclic graph into a cyclic state machine.

DAG · fixed path React · cycle A B C END agent tool calls? respond no yes → tool
Fig. 2.6 — the conditional edge (the "calls?" diamond) is the one architectural addition that turns a DAG into a self-correcting loop.

LangGraph is built to make that conversion safe: it offers recursion limits, state accumulation, and cycle detection precisely because cycles can misbehave (2.10). After every agent execution the graph asks one question, did the model ask for a tool, or give a final answer?, and the conditional edge routes on the answer.

2.7The router stays structurally pure#

The routing function is a plain Python function: it takes the current state, inspects the shape of the last message, and returns the name of the next node as a string. It reads whether tool calls exist; it does not read what they say.

route_after_agent
def route_after_agent(state: SupportState) -> str:
    """Pure router: inspect the LAST message, return the next node.
    The model already decided by emitting (or not emitting) tool calls."""
    last = state["messages"][-1]
    if getattr(last, "tool_calls", None):   # non-empty -> model wants a tool
        return "tools"
    return "respond"                        # no tool calls -> it's the final answer
⚠ PITFALL · business logic in the router

Never write if intent == "billing": return "tool_a" inside the router. That decision belongs to the LLM (via the system prompt) or to the classifier node. Hard-coding it makes the router a maintenance nightmare and duplicates logic the model is supposed to own. Keep the router structurally pure: it inspects message shape, nothing more. A pure router is also maximally reusable across different agent configurations.

2.8State accumulates: the message ledger#

Each pass through the loop appends to the message list. Because the field uses the add_messages reducer, nothing is overwritten, the list grows into a full record of the conversation and the reasoning history.

user query → AI · thought + tool_call → ToolMessage · observation → AI · thought + tool_call → ToolMessage · observation ↻ AI · final answer

When the agent node runs a second time, it receives the entire prior context, the earlier thought and the tool's observation included. That's the whole mechanism by which observations feed back into reasoning: there is no special API, just message accumulation in the context window. It's elegant, and it's also the seed of the next two problems.

2.9Context bloat, and three ways to tame it#

The most dangerous failure of a React agent isn't a crash, it's a slow degradation. Each iteration adds tokens (a thought is ~50–150, a tool call ~30–80, an observation ~50–500). Five iterations might only reach ~3,000 tokens, which is nothing. But a realistic enterprise ticket can need 15–20 tool calls, and a gnarly billing dispute can push context past 50,000 tokens. At that scale three problems emerge.

Lost in the middle#

Models attend strongly to the start and end of the context and under-attend the middle, exactly where your earlier reasoning and observations sit.

Latency#

Attention is O(N²). Context twice as long takes much more than twice as long to process.

Cost#

Billing scales roughly linearly with input tokens. Long loops with large observations get expensive fast at volume.

Once context is already huge, you often can't recover without starting over, so the real move is to manage growth as it happens. Three strategies, from crudest to most surgical:

StrategyHow it worksTrade-off
1 · Messages modifierTransform the message list before it hits the model (e.g. trim_messages). Rolling window: keep system prompt + original user turn + last N.Simplest, most predictable. Lossy, drops the middle entirely.
2 · Summarization nodeWhen token count crosses a threshold, a node calls a cheaper model to write a structured summary, then replaces old messages with it.Preserves meaning; adds an LLM call per summary. Risk: losing exact numbers.
3 · Selective preservation ★Extract key facts from each observation into a structured TypedDict (e.g. customer_facts). Then trim messages aggressively. Most effort, best result. Facts stay exact even as the transcript is cut.

Strategy 1 transforms the view the model sees without mutating stored state, the full history is retained, but the model receives a trimmed slice:

strategy 1 · trim to a rolling window
from langchain_core.messages import trim_messages

def prepare_messages(state: SupportState) -> list:
    return trim_messages(
        state["messages"],
        max_tokens=2000,
        strategy="last",        # rolling window: keep the most recent
        token_counter=llm,
        include_system=True,    # never drop the system prompt
        start_on="human",       # keep the original user turn intact
    )

Strategy 3 is the one worth the extra code. Instead of trusting a trim or a summary to keep what matters, you explicitly pull the critical fields out of each observation and store them in a queryable state field. The message list can then be cut hard, the facts survive independently:

strategy 3 · extract facts into TypedDict state
def capture_facts(state: SupportState, observation: dict) -> dict:
    facts = dict(state.get("customer_facts", {}))
    for key in ("billing_status", "outstanding_balance", "tier"):
        if key in observation:
            facts[key] = observation[key]          # exact values, not prose
    return {"customer_facts": facts}               # merged back into state
⚠ PITFALL · summaries that drop the numbers

A lazy summary writes "patient has elevated blood pressure." The clinically useful version is "BP 185/112 on 2024-03-15." Same lesson for agents: a summarization prompt must be structured to demand exact numbers, transaction IDs, amounts, and dates. Lose those and the summary is worse than useless, it looks complete while having thrown away the answer.

💡 TIP · use a cheaper model to summarize

Summarization is a simpler task than multi-step reasoning, so run the summary node on a lower-capability, cheaper model than the main agent. You pay a small latency/cost tax to avoid the far larger cost of context overflow.

2.10Keeping the loop bounded#

A cyclic graph can loop forever. It happens for three reasons: a tool-failure cascade (a tool keeps erroring and the model keeps retrying it), oscillating reasoning (the model alternates between two hypotheses and never commits), or context corruption (accumulated observations now contradict each other). A stuck agent is expensive, thousands of wasted tokens per run, so production agents layer several guards.

agent · iter += 1 iter > max? circuit breaker yes escalate → END no tool calls? yes tool no respond → END
Fig. 2.10 — two exits: graceful escalation at the iteration ceiling, or normal termination when the model stops asking for tools.

The guards work at two levels. The recursion limit is an infra-level backstop set at compile/invoke time; keep it low so a runaway graph crashes fast against it rather than burning minutes. The iteration count is application-level and smarter, when it trips, it produces a graceful, user-friendly message instead of a 500 error.

graph = builder.compile()
result = graph.invoke(initial_state, config={"recursion_limit": 15})  # infra backstop
★ TRICK · fingerprint repeated tool calls

If the model calls the same tool with the same arguments twice, it's stuck, the result (or error) will be identical, so the second call is pure waste. Store a fingerprint of every call, hash(tool_name + sorted(args)), in a set. Before executing, check membership; if it's a repeat, short-circuit with a synthetic tool message instead of running it. This turns a would-be infinite loop into bounded execution.

repeated-call detection
import hashlib, json

def call_fingerprint(name: str, args: dict) -> str:
    payload = name + json.dumps(args, sort_keys=True)   # sort so arg order can't fool it
    return hashlib.sha256(payload.encode()).hexdigest()

def is_repeat_call(state: SupportState, name: str, args: dict) -> bool:
    return call_fingerprint(name, args) in state["seen_calls"]  # seen_calls: set[str]
Analogy · the miniature circuit breaker

The household MCB (miniature circuit breaker) trips when current crosses a safe threshold and physically opens the circuit. A software circuit breaker is the same: detect that a dependency is failing at an unacceptable rate, stop forwarding requests, and return a cached or graceful response until it recovers, instead of letting the failure cascade.

⚠ PITFALL · why max-iterations alone isn't enough

"Just set max iterations to 50" doesn't cover the case where the agent is calling the same tool with the same args on iteration 3, why wait until 50 to stop obvious waste? Iteration count caps how many steps; fingerprinting kills redundant steps immediately; and the recursion limit sits below both as the hard infra floor. You want all three, not one.

2.11Graceful escalation to a human#

When a breaker fires, the agent must not just throw an error. The production requirement is a graceful degradation: a message that tells the user what was tried and hands the next human a briefing. The template has four parts and is hardcoded, not generated.

hardcoded escalation template
ESCALATION = (
    "I've logged your request about {topic}.\n"          # 1. acknowledge the request
    "Here's what I checked: {findings}.\n"               # 2. what was gathered
    "I couldn't fully resolve this automatically ({reason}), "   # 3. couldn't resolve + why
    "so I've routed it to a specialist. "
    "Someone will follow up within {sla}."               # 4. human follow-up + timeframe
)
⚠ PITFALL · don't ask the LLM to write the failure message

The breaker fired because the model was misbehaving, calling it again to compose the escalation invites another failure or a hallucinated promise. Hardcode the template in the node's exit logic so the graceful path can't itself fail.

2.12Interview & practice Q&A#

Why can't a transformer "think" without writing tokens?

answer Transformers are stateless sequence processors with no persistent working memory separate from the context window. Each token is conditioned only on preceding tokens. So the only way to hold intermediate reasoning is to emit it as tokens into the context, that emitted text is the scratchpad the model reads on the next step.

What exactly makes a graph "React" rather than a DAG?

answer The presence of a cycle. A DAG has a fixed acyclic path set at design time. A React graph adds a conditional edge after the agent node that can route back to a tool node and then to the agent again, forming an agent⇄tool loop, a cyclic state machine that self-corrects until it decides to respond.

Why must the router contain no business logic?

answer The router's only job is to inspect the shape of the last message (does it carry tool calls?) and return the next node. Putting if intent == "billing" logic in it duplicates a decision the LLM or classifier already owns, creates a maintenance burden, and makes the router non-reusable. Keeping it structurally pure keeps it reusable across agents.

Name the three context-management strategies and rank them.

answer (1) Messages modifier / trimming, a rolling window keeping system prompt, original user turn, and last N messages, simplest but lossy. (2) Summarization node, a cheaper model compresses old messages into a structured summary. (3) Selective context preservation, extract key facts into a TypedDict so messages can be trimmed hard while facts stay exact. Number 3 is the best; number 1 is the easiest.

Why is context bloat a "slow" failure, and why does latency scale badly?

answer It degrades quality gradually rather than crashing: lost-in-the-middle attention means earlier steps get ignored. Latency scales super-linearly because attention is O(N²), doubling context far more than doubles processing time; cost scales roughly linearly with input tokens on top of that.

How does fingerprinting stop an infinite loop, and how is it built?

answer A repeated call with identical arguments yields an identical result or error, so it can't make progress. You hash tool_name + sorted(args) into a fingerprint and store it in a set. Before executing a call, check the set; on a hit, short-circuit with a synthetic tool message. Sorting the args ensures argument order can't disguise a duplicate.

Recursion limit vs iteration count, what's the difference?

answer The recursion limit is an infra-level backstop set at compile/invoke time; it aborts the graph hard to catch bugs and should sit below the application limit so it fires first on true runaways. The iteration count is application-level and enables graceful degradation, a human-friendly escalation message instead of a raw graph-recursion exception.

Why hardcode the escalation message instead of generating it?

answer The circuit breaker fired because the agent was already failing. Making another LLM call to write the apology risks the same failure or a hallucinated commitment. A hardcoded four-part template (acknowledge, findings, couldn't-resolve + why, follow-up + SLA) guarantees the graceful path can't itself break.

2.13Key takeaways#

React earns its complexity only on multi-step, multi-tool, self-correcting tasks; for a lookup, skip it. The loop is Thought → Action → Observation, powered entirely by message accumulation, which is also why the two hard engineering problems are context growth and termination. Tame context with trimming, summarization, or, best, selective fact extraction. Bound the loop with a low recursion limit, an application iteration cap, and call-fingerprinting, then fail gracefully into a hardcoded human hand-off. And keep the router pure: it inspects message shape, never business logic.

Reason + Act scratchpad Thought→Action→Observe cyclic graph conditional edge pure router add_messages lost in the middle O(N²) trim / summarize / preserve recursion limit fingerprint set circuit breaker graceful escalation

Section 3

Microexercises#

Reading proves you recognise it. These prove you can use it. Attempt each before opening the walkthrough.

3.1Diagnose it: the impure router#

// your task

The router below is meant to decide the next node after the agent runs. It works on the happy path, then a teammate adds a new tool and everything routes wrong. Do three things:

1 · Name the root cause.   2 · Predict what goes wrong when the model calls search_knowledge_base (not billing).   3 · Rewrite it as a pure router. Expected pure behaviour: return "tools" whenever the last message has tool calls, else "respond".

broken_router.py
def route_after_agent(state):
    category = state["category"]
    if category == "billing":
        return "tools"          # billing always needs the CRM tool... right?
    return "respond"
Show the full walkthrough

Root cause. The router decides on business state (category) instead of message shape. It has smuggled business logic into a function whose only job is to inspect the last message. That's the exact pitfall from 2.7.

Step 1 — trace a non-billing tool call. A technical ticket makes the model emit a search_knowledge_base tool call. But category != "billing", so the router returns "respond" immediately.

Step 2 — predict the failure. The graph skips the tool node entirely. The respond node reads the last AI message, which has no answer text yet (the model was waiting on the tool), so the user gets an empty or malformed reply, and the tool call is silently dropped. No exception is raised; it just produces the wrong output, the worst kind of bug.

Step 3 — make it pure. Route on whether the last message carries tool calls, nothing else:

def route_after_agent(state):
    last = state["messages"][-1]
    if getattr(last, "tool_calls", None):   # any tool, any category
        return "tools"
    return "respond"

// why The model already encodes its intent by emitting (or withholding) tool calls. The router's job is to read that signal, not re-derive it, which is what keeps it correct for every tool and reusable across agents.

3.2Build it: the repeated-call guard#

// your task

Write the loop guard from 2.10 so an agent can't re-run an identical tool call. Two functions:

1 · fingerprint(name, args) → a stable string that is identical for the same tool and args regardless of key order.   2 · guard(seen, name, args) → if this call was seen before, return the string "DUPLICATE"; otherwise record it and return "OK". Verify: {"id":"C1","x":1} and {"x":1,"id":"C1"} must collide.

Show the full walkthrough

Step 1 — make the fingerprint order-independent. A dict can serialize two ways for the same content, so sort the keys. json.dumps(args, sort_keys=True) forces one canonical form; hashing keeps it compact.

Step 2 — the guard mutates a set. A set gives O(1) membership and stores each fingerprint once. Check first, then add.

import hashlib, json

def fingerprint(name, args):
    payload = name + json.dumps(args, sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()

def guard(seen: set, name, args) -> str:
    fp = fingerprint(name, args)
    if fp in seen:
        return "DUPLICATE"        # short-circuit: same call, same result
    seen.add(fp)
    return "OK"

Step 3 — verify the collision.

seen = set()
guard(seen, "get_customer_details", {"id": "C1", "x": 1})   # -> "OK"
guard(seen, "get_customer_details", {"x": 1, "id": "C1"})   # -> "DUPLICATE"

Both dicts serialize to {"id":"C1","x":1} after sorting, so their fingerprints match and the second call is caught.

// why An identical call can only reproduce an identical result or error, so catching it converts a potential infinite loop into bounded execution, cheaply, without waiting for the iteration cap.

route on shape, not state tool_calls? → tools sort_keys=True hash → set duplicate = short-circuit