Why Your Agent Loops Forever: Put the Review Step Outside the Executor
An agent loops when nothing in the loop can decide success or failure; a deterministic check on system state, reporting only to the planner, gives it an exit

An executor agent calls the same tool for the 60th time. The system it operates on has not changed since the second call. The model re-plans, picks the same call, and the context grows until the provider rejects the request. Nothing in the loop could tell it that this call cannot change anything.
The system under execution cannot change with that tool call, yet every call returns success. The executor receives a successful tool result each time, so from inside the loop the tool looks like it is working.
My position: agents loop because there is no definitive check of success or failure. The fix is a deterministic check of the goal against the state of the system, run by a review step that sits outside both planner and executor and reports only to the planner.
Why the ReAct Loop Has No Exit
A ReAct-style agent loop ends when the model stops asking for tools. Strands Agents describes its loop exactly that way: call the model, run any tools it requests, call the model again, and repeat until it produces a final response [1]. In Strands 1.62.0 no turn or token limit applies unless you pass one [1][2]. The OpenAI Agents SDK defaults to 10 turns and raises MaxTurnsExceeded [8]; LangGraph defaults to 1,000 steps and raises GraphRecursionError [9]. Either way, the loop's only exit is the model's own decision to stop, plus whatever budget you remembered to set.
Repeated identical calls are a measured failure, not an anecdote. One study counted instances where an agent called the same tool with identical arguments more than once. With step-by-step planning, Gemini-3-Flash did so in 45.5% of multi-objective HotpotQA instances and GPT-5-mini in 39.5% [10]. The repeats followed a failed or empty tool result [10].
On live websites, 34.2% of gpt-5-nano's actions produced no change in the page at all, and 10.4% of its failures came from repeating one action more than three times [12]. Across 1,794 Terminal-Bench trajectories, failed runs lasted a median of 27 steps, and "keeps repeating the same approach" accounted for 29% of all wasted steps [11].
Running out of context does not stop the loop either. When Strands catches a ContextWindowOverflowException, Agent asks its conversation manager to reduce the context and runs the cycle again [4]. The default SlidingWindowConversationManager keeps 40 messages and pins none, so the first message to be trimmed is the original task [5]. The agent keeps calling tools after it has lost the instruction that told it when to stop.
What a Review Step Is
A review step is the component that decides whether the goal is met. Most agent designs leave that decision to the model that is doing the work, and the evidence says this is the weakest place for it.
Anthropic reports that agents "reliably skew positive when grading their own work" and that separating the doer from the judge is a strong lever [14]. In a 54-cycle pilot study, an agent that judged its own progress claimed improvement every cycle, while 56% of cycles showed no measured gain or a decline [13]. A stronger in-band judge still accepted real regressions; the effect disappeared when success could be verified from the artifact itself [13]. On Terminal-Bench, 26% of failed trajectories fabricated success [11].
In my opinion, a review step is a deterministic check of the goal against the state of the system. It belongs to neither the planner nor the executor. It reports to the planner and never talks to the executor.
The definition puts the evidence in the environment. Anthropic's eval guidance makes the same distinction: the outcome is the final state of the environment, so a flight-booking agent passes when a reservation exists in the database, not when its message says the flight is booked [15]. A code-based check is cheap and reproducible; its cost is brittleness when a valid result does not match the expected pattern [15].
How the Planner/Executor Split Places Each Check
The sample runs on Strands Agents 1.62.0. ServiceEnv is a service that is down because its db_url points at a decommissioned database. restart_service always returns "restart ok" and changes nothing the goal can see. It is the tool from the hook: it succeeds, and nothing moves. Read the diagram from the top.
Orchestrator.run() owns the round budget, max_rounds. Each round it gives the planner the goal, the current state snapshot and the reviewer's findings from the previous round.
The planner is a Strands Agent with no tools. It writes a numbered plan and executes nothing.
The executor is a fresh Strands Agent each round, with the service tools, two bounds and nothing else in its history. Its first message is the plan text, so no reviewer output and no previous round's transcript ever reach it. The first bound is limits=Limits(turns=8, total_tokens=40_000) on every call, which ends the run with stop_reason="limit_turns" or "limit_total_tokens" instead of an exception [3]. The second is NoProgressGuard, a BeforeToolCallEvent hook:
# src/loopstop/guard.py def _before_tool_call(self, event: BeforeToolCallEvent) -> None: name = event.tool_use["name"] args = json.dumps(event.tool_use["input"], sort_keys=True) key = (name, args, self._fingerprint()) if key not in self._seen: self._seen.add(key) return self.tripped = f"{name}({args}) repeated with no state change" logger.warning("no_progress tool=%s args=%s state=%s", name, args, key[2]) event.cancel_tool = self.tripped event.agent.cancel(after_current_tools=True)The guard keys every call on its name, its arguments and a hash of the environment state. A deterministic tool called with the same input from the same state cannot produce a new outcome, so the second such call is pure cost. Setting cancel_tool replaces the call with an error result [6], and agent.cancel(after_current_tools=True) exits the loop before the next model call [4]. The executor never gets a turn to reason about the cancellation. In the test that scripts the restart loop, the run ends after two model calls and one restart.
GoalCheck.review() is the review step. It compares two snapshots of ServiceEnv and nothing else:
# src/loopstop/review.py def review(self, before: dict[str, Any], after: dict[str, Any]) -> Verdict: reasons = self.unmet(after) if not reasons: return Verdict("pass") if after == before: return Verdict("stalled", ("no state change this round", *reasons)) return Verdict("fail", tuple(reasons))On a pass, the run ends. A round that changed nothing ends it as stalled, whatever the executor said it did. On a fail, the orchestrator sends the reasons, such as service unhealthy with db_url=..., back to the planner for the next round. The full sample is in the tensorup-examples repo. Its 13 offline tests (uv run pytest -q) cover the restart loop, a transcript that claims success the state does not show, both limits, the round budget, and an executor whose second round starts from the plan alone.
I also ran it live against gpt-6-luna through the OpenAI Responses API, on an earlier version of the sample whose error message named the new database only as "db-new". In round 1 the executor scaled the service to three replicas and set db_url to postgres://db-new:5432/orders, a reasonable guess that was wrong, and GoalCheck returned fail. In round 2 it called get_status again from the same state, NoProgressGuard cancelled the repeat, and the run ended stalled after 11 model calls. The guard and the review step stopped a task the agent could not solve; they could not make it solvable. The current sample's error message names the full URL, and with it the same run passed in round 1 after 8 model calls.
The split works because each part sees a different thing: the planner sees state and findings, the executor sees a plan, and the review step sees only state. The next question is what the alternatives cost.
Design Decisions and Trade-offs
Turn limits alone. A cap stops the bill, and Strands' Limits stops gracefully with the message history intact [3]. It does not say whether the run failed or was about to succeed, and choosing the number is guesswork. In the sample the cap is a backstop and the guard is the detector.
Strands' GoalLoop plugin. Strands ships a goal plugin that validates the agent's response after each invocation [7]. It accepts a code validator or a natural-language goal judged by a second agent.
When validation fails, the plugin feeds the feedback back into the same agent as a new user message. That makes the executor its own reviewer's audience, which is the coupling this design avoids. Its max_attempts and timeout both default to infinity, and it warns that execution is then unbounded [7]. The default judge reads the transcript, with each tool result cut to 500 characters [7].
An LLM judge instead of code. A model-based grader handles open-ended goals that no assertion can express, at the cost of being non-deterministic and needing calibration against human graders [15]. For a goal you can state as a predicate over state (a healthy service with three replicas, say), code is the better reviewer. Anthropic's long-running agents work made features start as "failing" in a JSON file that agents could change only by flipping a passes field after testing [16].
Stopping on the first stall. The orchestrator ends the run after one round with no state change, rather than letting the planner try again. A planner given the same state and the same findings tends to produce the same plan. The cost is that a stall caused by a transient fault ends a run that a retry would have rescued.
Where It Breaks
The same call from the same state. Symptom: tool-call count climbs while every result is a success. Cause: the tool cannot change the state any further, and the loop has no way to notice [10][12]. Fix: fingerprint the goal-relevant state around each call and stop on a repeat, as NoProgressGuard does. Run tools sequentially: Strands' default ConcurrentToolExecutor runs a batch in parallel, and the fingerprint must not change under the guard [4].
A done check that reads the transcript. Symptom: the run reports success and the system is still broken. Cause: the judge sees the agent's claims, and failing agents fabricate success late in a run [11][13]. Fix: check the environment, as GoalCheck does; the sample's test with a "service is healthy" message and no state change ends stalled.
A fingerprint that is too coarse or too fine. Symptom: the guard stops a polling tool on its second call, or never fires at all. Cause: the fingerprint holds something that changes on every call (a timestamp, a restart counter), or misses something that matters. Fix: hash only the fields the goal is defined on, which is why restarts sits outside ServiceEnv.snapshot(), and give polling tools an exemption or a time field.
Checklist
- Write the success condition as code over system state, not over the agent's final message.
- Pass
limitswithturnsandtotal_tokenson every Strands executor call [3]. - Fingerprint goal-relevant state before each tool call and cancel a repeat from the same state.
- Run the executor's tools sequentially when a hook reads shared state.
- Send review findings to the planner only; start each executor run from the plan alone.
- Cap planner rounds and end the run on a round with no state change.
- Pin the task message (
pin_first) if you rely on the sliding window manager [5]. - Log the stop reason of every executor run and every verdict.
My verdict: an agent that cannot fail will loop, and the only reliable way to let it fail is a check that does not depend on the model's opinion of its own work. In my case the agent kept running for hours, through 50 to 100 tool calls, until it filled the context window and threw an exception, on a task that should have passed or failed within a few seconds.
What does your agent use to decide it is done?
References
- Strands Agents, Agent Loop, docs, updated 2026-10-21.
- Strands Agents,
event_loop/event_loop.py, GitHub, python/v1.62.0, 2026-10-21. - Strands Agents,
types/agent.py(Limits), GitHub, python/v1.62.0, last changed 2026-10-16. - Strands Agents,
agent/agent.py, GitHub, python/v1.62.0, last changed 2026-10-16. - Strands Agents,
sliding_window_conversation_manager.py, GitHub, python/v1.62.0, last changed 2026-06-16. - Strands Agents,
hooks/events.py, GitHub, python/v1.62.0, last changed 2026-09-25. - Strands Agents,
vended_plugins/goal/(GoalLoop), GitHub, python/v1.62.0, last changed 2026-09-30. - OpenAI,
run_config.py(DEFAULT_MAX_TURNS), OpenAI Agents SDK, GitHub, v0.24.0, 2026-10-15. - LangChain, LangGraph Graph API: recursion limit, docs, accessed 2026-10-25.
- Do Agents Need to Plan Step-by-Step? Rethinking Planning Horizon in Data-Centric Tool Calling, arXiv, 2026-05-08.
- Failure as a Process: An Anatomy of CLI Coding Agent Trajectories, arXiv, 2026-07-10.
- Why Do LLM-based Web Agents Fail? A Hierarchical Planning Perspective, arXiv, 2026-03-15.
- Park and Choi, When Do Agent Loops Mistake Stagnation for Progress?, arXiv, v2 2026-09-29.
- Rajasekaran, Anthropic, Harness design for long-running application development, 2026-03-24.
- Anthropic, Demystifying evals for AI agents, 2026-01-09.
- Anthropic, Effective harnesses for long-running agents, 2025-11-26.
Get the next one in your inbox
Each new post in your inbox, diagram included. Free.
By subscribing you agree to Substack’s terms of use, privacy policy and information collection notice. Your address goes to Substack, which sends the emails. This site stores nothing. Privacy notice