ReAct Agent Loops: Tool Use Through Thought, Action, and Observation
An agent that acts without reasoning is not reasoning. It is guessing with a tool schema.

Key topics
An agent that acts without reasoning is not reasoning. It is guessing with a tool schema.
Watch a failing tool-calling agent and the symptom is almost always the same shape. It fires three actions in a row with nothing between them, then emits a final answer that contradicts the last tool result it received. The trace looks confident. The behavior is incoherent.
The usual diagnosis is that the model is weak. Sometimes that is true. More often the loop is wrong. The runtime gave the model a channel for tool calls and a channel for text, then treated any text as a final answer. Reasoning never had a place to live, so it never happened. What you built was a tool-calling loop wearing the ReAct label.
This article is about the transition contract underneath the label. I want you to be able to read any agent runtime and answer one question: does this thing actually interleave reasoning with action, or does it just call tools until the model stops calling tools? Then I want you to build the smallest faithful version, break it in the two ways it always breaks, and instrument it so the next failure is attributable to a specific step instead of a vague sense that the agent got confused.
The ReAct Transition Contract
ReAct is a per-step transition rule, not a framework feature. The original formulation interleaves a natural-language thought with a structured action, executes the action, and feeds the environment's observation back into the same context. The cycle is thought → action → observation → thought. Not action → action → action.
The state of the loop is the scratchpad: the accumulated trace of thoughts, actions, and observations. That trace is what makes the next decision possible. Strip it out and the model has no memory of what it already tried, no way to notice it is repeating itself, and no basis for choosing between two plausible next actions. The scratchpad is not logging. It is the working memory the transition reads from and writes to.
Termination is the part most implementations get subtly wrong. In the canonical pattern, the loop ends when the model emits an explicit stop action. That is different from the loop ending because the model stopped emitting tool calls. "No tool call" and "final answer" are not the same signal, and collapsing them removes the model's ability to think out loud without acting. A model that wants to reason about whether it has enough information has nowhere to put that reasoning if text is interpreted as termination.
Keep the conceptual model separate from the encoding. The concept is interleaved reasoning and acting over a shared scratchpad. The encoding is whatever message schema your runtime uses to represent a thought, an action, and a stop. A runtime can encode thoughts as a dedicated field, as a delimited block inside a text message, or as a separate message role. The encoding is an implementation detail. The transition rule is not.
The invariant worth holding onto: every action must be preceded by a thought that justifies it, and every observation must be fed back in a form the next thought can actually use.
If you have already worked through the generic agent loop — state, action, observation, feedback, termination — you have the vocabulary this article assumes. What changes here is the transition rule that sits between those components.
Knowledge check
Check your understanding
Answer this question before you continue.
Why Most Tool-Calling Loops Are Not ReAct
The gap between the canonical pattern and what popular runtimes execute is wider than most people assume, and it is worth naming precisely because the failure mode it produces looks like a model problem.
Many tool-calling runtimes break the loop the moment the model emits text instead of a tool call. The control flow is roughly while True: if not response.tool_calls: break. Text is interpreted as a final answer. This collapses thought and final answer into the same channel. The model can either call a tool or declare it is done, but it cannot reason before acting, because reasoning would terminate the loop.
Graph-based agent runtimes model the loop as model-node → tool-node → model-node. The reasoning trace, if it exists at all, is implicit in the model's output rather than a first-class transition the loop depends on. The graph is a faithful representation of tool-calling. It is not a faithful representation of interleaved reasoning, because the reasoning step is not a node the loop routes through.
The consequence is exactly the failure mode ReAct was designed to prevent: an agent that chains actions without ever articulating why. Each action is locally plausible. The trajectory is incoherent, because nothing in the loop forced the model to connect the previous observation to the next action.
Here is the test I use. Pull one full trace and check whether every action is preceded by a thought that names the goal of that action. Not a restatement of the task. Not a summary of the last observation. A thought that says what this specific action is for. If the trace has actions with no justifying thoughts between them, the runtime is doing tool-calling, not ReAct.
I want to be honest about the tradeoff, because the action-only loop is not a mistake. It is cheaper in tokens and latency, and for single-hop tool use with a known tool it is often the right choice. The reasoning step adds tokens without changing the decision when there is only one reasonable action. The problem is not that the action-only loop exists. The problem is calling it ReAct and then being surprised when it fails on multi-hop tasks that need the reasoning step.
Building the Smallest Faithful Loop
Before any framework, build the transition so the mechanism is visible. The step record is the whole design:
@dataclass
class Step:
thought: str | None
action: ToolCall | None
observation: str | None
status: Literal["ok", "error", "stop"]
The scratchpad is a list of these, re-serialized into the next prompt. The loop is an explicit state machine. The parser returns one of three mutually exclusive kinds, and the loop refuses to accept an action that has no thought attached:
def run(task: str, tools: dict, max_steps: int = 12) -> str:
scratchpad: list[Step] = []
for _ in range(max_steps):
raw = model(prompt(task, scratchpad, tools))
parsed = parse(raw) # raises MalformedStep on unknown shape
if parsed.kind == "stop":
return parsed.answer
if parsed.kind == "action":
if not parsed.thought or not parsed.thought.strip():
scratchpad.append(Step(None, None, "rejected: action without thought", "error"))
continue
scratchpad.append(Step(parsed.thought, parsed.action, None, "ok"))
try:
result = tools[parsed.action.name](**parsed.action.args)
scratchpad.append(Step(None, None, format_observation(result), "ok"))
except Exception as exc:
scratchpad.append(Step(None, None, format_error(exc), "error"))
else:
raise MalformedStep(raw)
return force_finalize(scratchpad)
The prompt contract is what makes the transition reliable. The model must be told the exact output shape for a thought, an action, and a stop, and the parser must reject anything that does not match. A permissive parser that guesses at malformed output is how you get an agent that silently skips the reasoning step. Reject the output, re-prompt with the parse error, and count the retry against your step budget.
The parser's job is to make the three kinds mutually exclusive. A thought-only turn is not a stop. An action without a thought is not a valid action. A stop is not a fallback for text the parser did not understand. Encode that as three explicit variants:
# parse() must return exactly one of:
# {"kind": "thought", "text": "..."} # reasoning only, no action yet
# {"kind": "action", "thought": "...", "action": {"name": ..., "args": {...}}}
# {"kind": "stop", "answer": "..."}
A thought-only turn is legal and useful: it lets the model reason about whether it has enough information before committing to an action. The loop should append it to the scratchpad and continue, not terminate. That is the whole point of separating thought from final answer.
Here is what one iteration looks like in a real trace, with the routing made explicit:
[thought] I need the birthplace of the discoverer of gravity before I can query weather.
[action] wikipedia(query="Isaac Newton birthplace")
[observe] {"status": "ok", "summary": "Woolsthorpe, England", "payload": {...}}
[thought] I have the location. Now I can call the weather tool with that argument.
[action] weather(location="Woolsthorpe")
[observe] {"status": "ok", "summary": "10C, clear", "payload": {...}}
[stop] Newton was born in Woolsthorpe. Current weather there is 10C and clear.
Read the trace and check the invariant directly. Every [action] line is preceded by a [thought] line that names the goal of that action. The second thought is not a restatement of the task; it is the bridge from the first observation to the second action. If your trace has an [action] with no preceding [thought], the loop is not doing what you think it is.
Keep the first version single-tool and single-threaded. Parallelism and sub-agents are a later concern that obscures the transition, and you cannot debug a transition you cannot see.
Dry-run one multi-hop task and print the scratchpad after each iteration. A task like "find who discovered gravity, then get the weather where that person was born" forces two dependent tool calls with a reasoning step between them. If your scratchpad shows the second action without a thought connecting the first observation to it, the loop is not doing what you think it is.
Knowledge check
Check your understanding
Answer this question before you continue.
Where the Loop Breaks: Unbounded Reasoning
The first failure class is the loop that never converges because reasoning expands without producing a decision.
The symptom is distinctive. The model restates the task, re-plans, and re-reasons without emitting an action or a stop. Token cost grows while progress stays flat. The scratchpad fills with thoughts that look productive and contain no new information.
The root cause is structural, not behavioral. There is no budget on steps or tokens, and nothing in the scratchpad distinguishes "I am making progress" from "I am repeating myself." The model has no signal that it is stuck, because the loop never told it what stuck looks like.
Three mitigations, in order of how much I trust them:
- Hard step and token caps. Non-negotiable. The cap is not a quality mechanism; it is a blast radius limit.
- A repetition check over recent thoughts. Cheap heuristics work here — normalized similarity over the last three thoughts catches most loops. This is a signal, not a gate.
- A forced-finalize path that converts the scratchpad into a best-effort answer instead of failing. A partial answer with visible gaps beats an exception.
The judgment call is distinguishing a genuine long-horizon task from a stuck loop. The first needs more budget. The second needs a different termination rule. You cannot tell them apart from step count alone. You tell them apart by reading the last three thoughts and asking whether each one added information the previous one did not have.
Knowledge check
Check your understanding
Answer this question before you continue.
Where the Loop Breaks: Tool Errors and Bad Observations
The second failure class is the loop that receives a useless or misleading observation and cannot recover. This is where most production agents actually die, and it has three distinct modes.
Hard errors — exceptions, timeouts, 4xx and 5xx responses. Empty or truncated results. And the dangerous one: plausible-but-wrong results that look like success. A search tool that returns a confident paragraph about the wrong entity is worse than a tool that returns a 500, because the loop has no reason to distrust it.
The loop's recovery behavior depends entirely on how the observation is formatted. A raw stack trace invites the model to reason about the trace instead of the task. I have watched agents spend four steps debugging a tool's internal error message instead of retrying with corrected arguments.
Design the observation contract explicitly. Return a structured status, a short human-readable summary, and the payload. Keep the payload bounded so one large tool result does not evict the scratchpad.
{
"status": "error",
"error_class": "timeout",
"summary": "weather lookup timed out after 5s for location 'Woolsthorpe'",
"payload": null,
"retryable": true
}
The error_class and retryable fields are doing real work. They give the next thought something to reason about that is about the task rather than about the failure. A model that sees retryable: true and a timeout will retry. A model that sees a stack trace will speculate.
Plan for idempotency and retry limits before you plan for retries. A retried action that mutates external state is a different risk class than a retried read. The loop does not know the difference unless you tell it, and "the agent sent the same email three times" is a failure mode that ends projects.
Here is the trace I use to teach this. The agent has a correct plan: look up a paper, then look up its citation count. The first tool returns a result with a null identifier field. The agent's next thought abandons the plan and starts a fresh search, because the observation did not include the field the next step needed. One malformed observation, one abandoned plan, one wasted trajectory. The fix is not a better model. The fix is an observation contract that preserves the identifier.
Knowledge check
Check your understanding
Answer this question before you continue.
Observation Quality Is a Context Engineering Problem
Every observation competes for the same context window as the scratchpad. Verbose tool output is the most common cause of a loop that degrades after a few iterations, and the degradation is gradual enough that people blame the model.
Summarize or truncate observations at the boundary, not inside the model. Decide what the next thought needs before the observation is written into the scratchpad. This is a harness decision, and it belongs in your code, not in a prompt instruction asking the model to ignore irrelevant output.
Preserve the fields the next decision depends on — identifiers, counts, status, error class — and drop the rest. Losing an identifier forces a redundant tool call, which costs tokens and adds a step where a new failure can occur. The rule I use: if the next action plausibly needs a field to construct its arguments, that field survives summarization.
There is an open question here that I do not think has a clean answer yet. Aggressive summarization can silently drop the one detail that mattered, and the failure is invisible because the summary looks reasonable. That means the summarizer itself needs an evaluation. If you are summarizing tool output with a model, you have added a second model to your loop, and it has its own failure modes. Measure it separately.
Evaluating the Loop, Not Just the Answer
Score the trajectory, not only the final answer. A passing final answer reached through a broken trajectory is a latent failure. It will not generalize to the next task, and you will not know why when it stops working.
The metrics that have earned their place in my own instrumentation:
| Metric | What it catches |
|---|---|
| Step count | Unbounded reasoning, redundant calls |
| Tool-call precision | Wrong tool selected for the goal |
| Redundant call rate | Scratchpad not being read |
| Recovery rate after failed observation | Observation contract quality |
| Stop justification | Premature or unjustified termination |
Two of those metrics need a concrete check, not just a name. The first is the invariant itself. Walk the scratchpad and assert that every action record has a preceding thought record with non-empty text. That is a five-line check, and it turns "is this actually ReAct?" from a debate into a boolean:
def check_invariant(scratchpad: list[Step]) -> list[str]:
violations = []
for i, step in enumerate(scratchpad):
if step.action is not None:
prev = scratchpad[i - 1] if i > 0 else None
if prev is None or not prev.thought:
violations.append(f"step {i}: action without preceding thought")
return violations
The second is stop justification. A stop is only valid if the scratchpad contains the evidence the task required. Define that evidence per task — for a multi-hop lookup, it is the final observation that answers the last sub-question — and assert it before accepting the stop. A stop that arrives before the required evidence is a premature termination, and it should be logged as a failure, not returned as an answer.
Build a small set of tasks with known multi-hop structure so a regression in the loop shows up as a step-count or recovery-rate change. Five tasks is enough to start. The point is not coverage. The point is a baseline you can measure every later change against.
Log structured events per step — thought, action, observation, status — so a failed run can be replayed instead of guessed at. This is the same discipline as structured logging in any other system, and it pays off the first time you need to answer "why did it do that" without re-running the task.
When ReAct Is the Wrong Loop
The pattern has a decision boundary, and crossing it in the wrong direction costs tokens and latency for no gain.
Single-hop tool use with a known tool: a direct tool-calling loop is cheaper and equally correct. The reasoning step adds tokens without changing the decision.
Deterministic multi-step workflows with fixed ordering: an explicit pipeline or state machine is more debuggable than a model choosing the next step. If the sequence is known, encode the sequence.
Latency-critical paths: interleaved reasoning roughly doubles the number of model calls per task. On user-facing requests, that is a real cost, and it needs to buy something.
Tasks where the environment is the bottleneck rather than the reasoning: more thinking does not help if the tool cannot return better data. Fix the tool first.
The pattern earns its cost when the task requires multiple dependent tool calls, the next action depends on the previous observation, and the sequence is not known in advance. That is the case ReAct was built for, and it is the case where the reasoning step changes the outcome.
The Next Move
Before you add any framework, run one trace and verify two things: every action is preceded by a thought that justifies it, and every observation is small enough to leave room for the next one. If either fails, the framework is not the problem.
Then instrument the loop with per-step structured logging, build a five-task multi-hop set, and record step count and recovery rate as your baseline. Add the invariant check and the stop-justification check to your test harness so both run on every trace. Every later change — a new tool, a summarizer, a different model — gets measured against that baseline. The loop is the system. Treat it like one.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


