ReAct Loop
The reason/act/observe cycle, and where it goes wrong.
Problem
Some tasks cannot be planned. "The pallet arrived crushed, three units unusable, order 4921" requires finding out whether the order exists, what shipped, which damage policy applies, and what the credit works out to, and the second question depends on the first answer.
A fixed plan written before any of that is known is a plan full of guesses. A single model call with no tools cannot look anything up.
What is needed is a cycle: the model states what it is trying to establish, takes an action, reads the result, and decides again with that result in hand. That is ReAct, reason, act, observe, introduced by Yao et al. in 2022 and the shape underneath nearly every agent framework since.
It is also the shape that produces most agent failures in production, and this entry is mostly about that.
Forces
- Adaptivity is the whole value. The next step is genuinely unknowable until the last observation.
- The loop has no natural stopping condition. Nothing about "read a result and decide" says when to be finished.
- Cost grows superlinearly: the transcript is re-sent every turn, so input tokens grow roughly with the square of turn count.
- Observations are attacker-influenced and they are the input to the next control-flow decision.
- The reasoning text is not a plan, it is a post-hoc-looking justification produced in the same breath as the action.
- A single wrong observation propagates, because everything after it reasons from it.
Solution
The cycle itself is four lines. What makes it survivable in production is everything wrapped around it.
┌───────────────────────────────────────────────────────┐
│ ① turn cap hard stop, produces a partial │
│ ② budget cap tokens and money, degrade then stop│
│ ③ deadline wall clock, propagated per call │
│ ④ no-progress N turns with no new information │
│ ⑤ repetition same tool + same args twice │
└───────────────────────────────────────────────────────┘
│ all five wrap:
▼
┌───────────────────────────────┐
│ REASON what am I trying │
│ to establish? │
│ ▼ │
│ ACT one tool call │
│ ▼ │
│ OBSERVE result enters │
│ the transcript │
└──────────┬────────────────────┘
│
done? ── no ──▶ loop
│
yes ──▶ answer, or ESCALATEFour rules:
Every exit path is defined, including the ugly ones. Finished, out of turns, out of budget, out of time, not making progress, going in circles. Each produces a specific outcome: an answer, a partial result, or an escalation with what it has. "It should converge" is not a termination condition, and missing termination is a named failure mode in the empirical taxonomy.
Detect no-progress, not just turn count. An agent can burn its whole budget productively-looking: searching, re-reading, rephrasing. Track whether each turn added a new fact to the scratchpad; three turns without one means stop.
Detect repetition explicitly. The same tool with the same arguments twice is a loop, and the model will not notice because each turn looks locally reasonable. Hash (tool, canonical(args)) and refuse the second call with an instruction rather than an error.
Keep the observation out of the control-flow decision where you can. The taint ceiling applies per turn: a run that has read external content cannot reach a high-class tool, whatever the next reasoning step concludes. This is what stops a retrieved instruction from steering the loop.
Code
export async function react(state: RunState, ctx: RunContext): Promise<Outcome> {
const seen = new Set<string>();
let barren = 0; // consecutive turns adding nothing
while (true) {
// ── ① ② ③ every bound checked before the expensive call ──
if (state.turn >= MAX_TURNS) return partial(state, 'turn_cap');
if (ctx.budget.exhausted()) return partial(state, 'budget');
if (ctx.deadline.passed()) return partial(state, 'deadline');
if (barren >= MAX_BARREN) return escalate(ctx, 'no progress', state);
const step = await callModel(state.toRequest());
state.turn += 1;
if (!step.toolCall) return finish(state, step.text);
// ── ⑤ repetition: locally reasonable, globally a loop ──
const key = `${step.toolCall.name}:${canonical(step.toolCall.args)}`;
if (seen.has(key)) {
state.record(step, errorForModel(
'repeated_call',
'That call was already made with identical arguments this run. ' +
'Use the earlier result, try something different, or conclude.',
));
barren += 1;
continue;
}
seen.add(key);
// Taint and scope are enforced here, per turn — an observation can
// change what the model wants, never what it is permitted to do.
const before = state.scratchpad.factCount;
state.record(step, await dispatch(step.toolCall, ctx));
barren = state.scratchpad.factCount > before ? 0 : barren + 1;
}
}barren is the check most implementations lack and the one that saves the most money. A turn cap stops a runaway eventually; a no-progress counter stops it three turns in, and it distinguishes stuck from slow, which matters, because the correct response to stuck is escalation and the correct response to slow is patience.
Trade-offs
Cost is superlinear in turns and hard to predict. A twenty-turn run is not twenty times a one-turn run; on the input side it is closer to two hundred. This makes p99 run cost the number to watch, and the turn cap an economic control as much as a safety one.
Reasoning text is not a commitment. The model saying "I will check the policy, then compute the credit" is not a plan. It may do neither. Do not build approval flows or audit records on the assumption that stated intent predicts the next action; use an actual plan object if you need that.
One bad observation poisons everything after it. The loop's strength, each decision informed by the last result, is also how a wrong tool result becomes a confidently wrong conclusion. The defenses are re-reading rather than recalling and re-deriving values server-side, not better prompting.
Latency is a serial chain. Every turn is a full round trip, and nothing can be parallelized because step n+1 is unknown until step n returns.
When not to use it
When the steps are knowable. The determinism test: if you can enumerate the sequence, write it. A loop that does the same five things every time is a workflow paying a model to rediscover it.
When the plan needs to be inspected or approved. A reviewer cannot approve a loop. Plan-then-execute produces the artifact.
On high-authority paths reading untrusted content. The loop's control flow is the thing injection targets. Either fix the plan first, or accept the loop and make sure the capability ceiling is doing the work, never rely on the reasoning to resist.
When latency budgets are tight. Six serial round trips is not a sub-second experience. Route to a shorter path, or pre-load and answer in one call.
The loop is four lines; the bounds are the engineering
Every framework ships a ReAct implementation, and it is genuinely trivial to write. That accessibility is why the pattern is so often deployed with none of the five bounds attached, and why the resulting systems fail in the same handful of ways.
The failure modes are not exotic. An agent that never terminates. An agent that calls the same tool nine times with the same arguments. An agent that spends forty dollars answering a question worth four. An agent that reads a hostile document and follows it.
None of those are fixed by a better prompt, and all four are prevented by counters and caps in the loop that calls the model. The interesting part of a ReAct agent is not the cycle. It is the code that decides when the cycle stops.
Related
- Plan Then Execute: the alternative when the sequence can be fixed in advance
- Bounded Autonomy: the five caps, treated as their own pattern
- Router: shortening the loop by specializing the path before it starts
- Reflection: adding a self-critique turn, and what it does and does not buy
- The Loop by Hand: building this from scratch, and the six things it cannot survive
References
- ReAct: Synergizing Reasoning and Acting in Language Models, Yao et al., 2022, the original statement of the cycle.