Skip to content
advanced

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.

Published 2026-09-11Updated 2026-09-1216 min read
3D abstract geometric structure with gold lines and black polygons on a dark background.
3D abstract geometric structure with gold lines and black polygons on a dark background. Photo by Maxim Landolfi on Pexels.

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

A sparse loop with four stages: Thought leads to Action, Action leads to Observation, and Observation returns to Thought through a shared Scratchpad; a separate Stop branch exits to Final answer.
A faithful ReAct loop routes each observation back through shared scratchpad state before choosing the next action; explicit Stop, not missing tool calls, terminates the run.

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.

Which transition sequence best matches the ReAct contract described in the article?
Single Choice

Focus: Identify the required state transition and termination signals in a faithful ReAct loop.

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.

A parser returns a thought-only result, but the runtime immediately returns that text as the final answer. What should be changed?
Debugging

Focus: Diagnose a loop implementation that incorrectly treats reasoning text as termination.

The parser variants are thought, action, and stop.

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.

A trace repeatedly restates the task and produces neither an action nor a stop. Which response best follows the article's mitigation strategy?
Scenario Interpretation

Focus: Select safeguards for an agent whose reasoning repeats without making progress.

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.

Which observation contract gives the next thought the most actionable information after a timeout?
Comparison Reasoning

Focus: Choose an observation representation that supports recovery from tool failures.

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:

MetricWhat it catches
Step countUnbounded reasoning, redundant calls
Tool-call precisionWrong tool selected for the goal
Redundant call rateScratchpad not being read
Recovery rate after failed observationObservation contract quality
Stop justificationPremature 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.

A summarizer is reducing a tool result before it enters the scratchpad. Which policy best matches the article?
Question 1 of 2Scenario Interpretation

Focus: Preserve decision-critical fields when reducing tool output in the agent context.

Which task is the strongest fit for ReAct rather than a direct tool call or fixed pipeline?
Question 2 of 2Comparison Reasoning

Focus: Determine when ReAct provides enough value to justify its additional reasoning cost.

References

  1. Gradientsys: A Multi-Agent LLM Scheduler with ReAct Orchestrationarxiv.org
  2. Is create_agent formally producing a ReAct agent? - LangChain - LangChain Forumforum.langchain.com
8sources checked
8source domains
10searches run

Research updated Sep 11, 2026

Related sites

Build the foundations behind advanced AI systems

Use LearnLLMFast for practical LLM application foundations and LearnPyFast for the Python mechanisms that support implementation work.

LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast
Python tutorialstutorial

LearnPyFast

Beginner-friendly Python tutorials, examples, and learning paths for practical programming foundations.

PythonProgrammingBeginners
Visit LearnPyFast

Keep exploring

Related AI engineering tutorials

Continue with adjacent system layers, implementation patterns, and current AI engineering ideas.