Loading...
Back to Patterns

Updated 2026-07-26

Architecture Pattern

Agent Loop Termination Pattern: Stop Conditions That Work (2026)

How to define 'done' for an agent loop: verifier done-checks, iteration caps, token/time budgets, and no-progress detection - plus the real framework defaults (LangGraph recursion_limit's 25-vs-1000 doc discrepancy, OpenAI Agents SDK max_turns=10, CrewAI max_iter=20) and how to size them so agents exit before the ceiling instead of crashing into it.

Pattern: Agent loop termination2026-07-26

Problem

Every agent loop needs an answer to 'when do we stop?' - and most teams discover their answer at 2 a.m. as a stack trace: LangGraph's 'Recursion limit of 25 reached without hitting a stop condition,' OpenAI Agents SDK's MaxTurnsExceeded, or a CrewAI agent silently forced to answer after exhausting max_iter. Framework ceilings are crash barriers, not stop conditions: hitting one means the work is lost (or half-done), the tokens are spent, and nothing recorded why the agent failed to converge. Worse, the defaults themselves are inconsistent - LangGraph's own documentation currently states two different recursion_limit defaults on two official pages - so teams don't reliably know what ceiling they are running under.

Use this pattern for any agent that loops: ReAct-style tool loops, multi-node graphs with cycles, planner-executor systems, and multi-agent handoffs. It matters most when iterations cost real money (LLM calls per step), when the agent can take externally visible actions (each extra loop is another chance to act wrongly), and when tasks vary in size so no single iteration cap fits all runs.

Components

  • Explicit done-check: a verifier step (rule-based assertion or a small judge model) that evaluates 'is the task complete?' against acceptance criteria, separate from the acting model's own claim of being done
  • Hard iteration cap set per task class, sized so healthy runs finish at well under half the ceiling - the cap is the airbag, never the brake
  • Resource budgets orthogonal to iterations: token budget, wall-clock budget, and cost budget, whichever binds first
  • No-progress detector: flags repeated identical tool calls (same tool, same arguments) and unchanged state between iterations via state hashing
  • Stop-reason taxonomy: every exit is labeled - done_verified, budget_exhausted, no_progress, max_iterations, human_abort - and emitted to observability
  • Escalation path: on non-done exits, the loop parks its state for human review or a replan step instead of silently returning a partial answer

Flow

  1. 01The task enters with explicit acceptance criteria and a budget envelope (max iterations, max tokens, max wall-clock) chosen for its task class - not a global constant.
  2. 02Each iteration, the agent acts, then the done-check runs: cheap deterministic assertions first (does the file compile? does the API call return 200? are all required fields present?), the judge model only when rules cannot decide.
  3. 03The no-progress detector compares this iteration's tool-call signature and state hash against recent iterations; two identical tool calls with identical arguments, or N iterations with an unchanged state hash, trigger a forced replan or exit - before the iteration cap does.
  4. 04Budget counters (iterations, tokens, dollars, seconds) decrement every loop; whichever exhausts first triggers a labeled exit, not an exception.
  5. 05On done_verified, the loop returns the result with the verifier's evidence attached.
  6. 06On any other stop reason, the loop exits gracefully: state is checkpointed, the stop reason and iteration count are logged, and the run is routed to escalation (human review or a planner retry with a modified approach) rather than crashing into the framework ceiling.
  7. 07Stop-reason distribution is monitored over time: a rising share of max_iterations or no_progress exits is a prompt/tool-design regression signal, visible before users complain.

Tradeoffs

Verifier done-checks vs hard caps

A verifier stops the loop for the right reason but costs an extra model call per iteration and can be wrong in both directions (premature done, endless not-done). A hard cap is free and certain but knows nothing about the task. Production loops need both: the verifier as the brake, the cap as the airbag. If the cap fires more than ~1% of runs, the cap is not too low - your loop design or acceptance criteria are broken, and raising the ceiling only raises the cost of each failure.

Sizing the ceiling: framework defaults are not designed values

The defaults differ by an order of magnitude across frameworks and even within one framework's docs. LangGraph's Graph API page (docs.langchain.com/oss/python/langgraph/graph-api) states recursion_limit defaults to 1000 as of v1.0.6, while the LangGraph SDK schema reference (reference.langchain.com/python/langgraph-sdk/schema/Config/recursion_limit) still documents 25. OpenAI Agents SDK Runner.run uses DEFAULT_MAX_TURNS = 10 (openai.github.io/openai-agents-python/ref/run/); CrewAI's Agent.max_iter defaults to 20, after which the agent is forced to produce its best answer (docs.crewai.com: 'Maximum iterations before the agent must provide its best answer. Default is 20.') (docs.crewai.com). None of these numbers knows anything about your task - a 10-turn default is far too small for a research loop and a 1000-step default is an unbounded token bill for a support bot. Set the limit explicitly on every entry point and treat an inherited default in production as a bug.

Counting supersteps vs turns vs iterations

The units differ, so the same number means different ceilings. LangGraph's recursion_limit counts supersteps (a multi-node graph burns several per logical 'turn', and GraphRecursionError fires when they run out - docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT); OpenAI's max_turns counts full model invocations including their tool calls; CrewAI's max_iter counts reasoning iterations within one agent's task. Porting a limit between frameworks without converting units silently changes your ceiling by the graph's branching factor.

Middleware and platform layers can eat your limits

The limit you set is only real if it survives the call path. CopilotKit issue #1717 ('Bug: CopilotKit overrides Langgraph recursion limits') documents the integration overriding the caller's recursion limit with no workaround, and follow-up issue #2197 shows the same failure as 'Recursion limit of 25 reached without hitting a stop condition' because the configured limit was never passed into the graph config. Also easy to get wrong first-hand: LangGraph takes recursion_limit as a top-level config key on invoke/stream - putting it inside configurable silently does nothing. Test your ceiling by forcing a loop in staging and confirming the run stops where you configured, not where a middleware default says.

Failure modes

  • 01The framework ceiling is the only stop condition: every overrun ends as GraphRecursionError or MaxTurnsExceeded with all work lost, instead of a graceful labeled exit with state checkpointed.
  • 02Self-reported completion: the acting model declares success and no verifier checks it - the loop exits early on hallucinated 'done' exactly as often as it spins on real work.
  • 03No-progress loops under the cap: the agent alternates between two tool calls or retries the same failing call with identical arguments for 40 iterations - within budget, achieving nothing. Signature-repeat detection catches in two iterations what the cap catches in forty.
  • 04One global limit for all task classes: sized for the biggest research task, it lets a broken support-bot loop burn 200 iterations of tokens; sized for the support bot, it kills legitimate long tasks. Budgets must be per task class.
  • 05The configured limit never reaches the runtime: passed in the wrong config location (inside configurable instead of top-level in LangGraph) or overridden by an integration layer, as in CopilotKit #1717/#2197 - the agent runs under a default nobody chose.
  • 06Silent forced answers: CrewAI-style max_iter exhaustion returns the agent's best-effort answer with no signal it was forced - downstream consumers treat a truncated result as a completed one unless the stop reason is surfaced.

Implementation notes

  • 01Set limits explicitly at every entry point: graph.invoke(state, {"recursion_limit": N}) as a top-level config key in LangGraph; Runner.run(agent, input, max_turns=N) in OpenAI Agents SDK; Agent(max_iter=N) in CrewAI. Write the chosen N next to the task class definition, with a comment stating the p95 healthy iteration count it was derived from.
  • 02Derive N from data, not vibes: log iteration counts for a week, then set the cap at roughly 2x the p95 of successful runs per task class. If healthy runs cluster at 8 iterations, a cap of 16 catches pathology fast; the LangGraph SDK-documented default of 25 would let a broken run triple-spend first.
  • 03Implement the done-check as a pipeline: deterministic assertions first (exit codes, schema validation, required-field presence - free and unfoolable), judge model second (only for tasks rules cannot decide). Never let the judge overrule a failing deterministic check.
  • 04No-progress detection is two cheap comparisons: hash each (tool_name, canonicalized_args) pair and keep the last K; a repeat forces a replan prompt ('this exact call already ran and returned X - choose a different approach'). Hash the task-relevant slice of state each iteration; unchanged for 3 iterations means exit no_progress.
  • 05Catch the ceiling as a signal, not a crash: wrap invocation to catch GraphRecursionError / MaxTurnsExceeded, checkpoint whatever state exists, emit stop_reason=ceiling_hit, and route to escalation. Pair this with a checkpoint-and-resume design so budget-exhausted runs can continue under a fresh budget after review rather than restarting from zero.
  • 06Track the stop-reason distribution as a product metric: done_verified share is your loop's convergence rate; a drift toward no_progress or ceiling_hit after a prompt or tool change is the earliest regression alarm you will get.