Loop Stop Reasons Belong in the Runtime

Sep 2 2026 · 9 min · Sieon

Agent loops should not end with a generic timeout and a confused caller. The runtime needs typed stop reasons that explain whether work should resume, retry, shrink context, ask a human, or fail permanently. A budget without an exit contract is only a crash with nicer telemetry.

The loop ends before the work is done

The most useful loop engineering rule is simple: every budget must produce a stop reason and every stop reason must map to a next action. A budget says how far the loop may go. A stop reason says why the loop stopped. The next action says what the platform is allowed to do next.

Most agent systems start with one knob because one knob is easy to explain. They add a maximum number of model turns, a maximum graph depth, a retry count, or a wall-clock deadline. That is a good safety baseline. The OpenAI Agents SDK describes its runner as a loop that calls the LLM, accepts final output, follows handoffs, executes tool calls, and re-runs until the work completes or a configured turn limit is exceeded. LangGraph documents GRAPH_RECURSION_LIMIT as the case where a graph reaches the maximum number of steps before hitting a stop condition.

Those limits are necessary, but they do not answer the operational question. Did the agent stop because it was looping on the same invalid tool call? Did retrieval keep returning no evidence? Did the user approval gate pause execution? Did a downstream API rate limit force a retry window? Did the task finish partially and need a human decision? All of those can look like "the loop stopped" if the runtime exposes only one exception.

Senior teams should treat stop reasons as part of the product API. The caller should not parse tool logs to decide whether to retry. The orchestrator should not infer recoverability from an exception class alone. The support dashboard should not collapse budget exhaustion, policy refusal, missing input, and dependency outage into the same red badge.

A max-turn cap is a guardrail, not an operating model

A max-turn cap prevents runaway execution. It is not a recovery strategy. The OpenAI Agents SDK documentation says that if the runner exceeds max_turns, it raises MaxTurnsExceeded, and that max_turns=None disables that turn limit. The public SDK source also defines MaxTurnsExceeded as the exception raised when the maximum number of turns is exceeded.

That is exactly the kind of primitive a runtime should provide. The mistake is letting that primitive leak upward as the entire semantics of failure. A customer-support agent that hits a turn cap because the refund policy is ambiguous is not the same incident as an agent that hits a turn cap because a tool schema changed. The former may need human review. The latter may need deployment rollback. The cap detected both, but it did not classify either.

Graph runtimes have the same shape. LangGraph's troubleshooting page explains that a StateGraph reached the maximum number of steps before a stop condition, often due to an infinite loop, while complex graphs may hit the default limit naturally. The source for GraphRecursionError says it is raised when the graph exhausts the maximum number of steps to prevent infinite loops and that a higher recursion_limit can be supplied.

Increasing the limit is sometimes correct. Shipping only the larger limit is usually incomplete. The runtime should also record whether the last several states showed progress, whether the same edge was traversed repeatedly, whether the same tool failed repeatedly, and whether the graph had a known resume point. Without that classification, operators learn only that the system survived long enough to stop.

Stop reasons should be product API, not internal logs

A typed stop reason is a stable value that callers can depend on. It should be small enough to document and coarse enough not to expose private implementation detail. The detailed trace can say which node, tool, prompt version, or provider failed. The API can return a reason such as budget_exhausted, needs_approval, dependency_limited, no_progress, invalid_tool_result, policy_blocked, or completed_partial.

A practical contract has four fields:

{
  "status": "stopped",
  "reason": "no_progress",
  "recoverability": "resume_after_change",
  "resume_token": "run_01J9...:step_42"
}

The nearby prose matters more than the JSON shape. status tells product code that the loop did not complete normally. reason is the low-cardinality classification used by routing and alerts. recoverability states what automation may do next. resume_token is optional, but when present it tells the caller that the runtime can continue from durable state instead of starting over.

The same reason should appear in observability. OpenTelemetry spans can add named events with attributes, so a runtime can emit a loop.stop event with attributes such as loop.reason, loop.turns_used, loop.turn_budget, loop.resume_available, and loop.last_progress_at. The trace can retain high-cardinality internals while the API keeps a stable public vocabulary.

The decision rule is: if two stop cases require different automation, they need different stop reasons. If they differ only in debugging detail, they need the same stop reason with different trace attributes.

Budgets compose: turns, steps, retries, and pods

Agent loops rarely run alone. One request may include an agent turn budget, a graph recursion limit, a tool retry policy, a queue visibility timeout, and a container restart policy. If each layer owns a separate budget with a separate failure vocabulary, the outer product sees randomness.

Durable workflow systems already show the pattern. Temporal Retry Policies describe fields such as initial interval, backoff coefficient, maximum interval, maximum attempts, and non-retryable error types. That is not only a retry counter. It is a policy that distinguishes retryable work from work that should stop retrying.

Kubernetes Jobs make a similar distinction at infrastructure level. The Kubernetes Job documentation includes backoffLimit, backoffLimitPerIndex, maxFailedIndexes, and podFailurePolicy. The important lesson for agent teams is not to copy those fields directly. The lesson is that mature runtimes separate retry budgets from failure classification.

Python retry libraries expose the same idea in application code. Tenacity supports stop conditions such as stop_after_attempt and stop_after_delay, along with wait strategies. A loop runtime can use those mechanics internally, but the external reason should still say why the policy stopped: too many transient failures, non-retryable input, dependency throttling, or no progress.

This composition is where many agent platforms burn money. A model loop hits a turn cap, the job worker retries the whole request, the graph starts from the beginning, and a downstream workflow retries the activity again. Each layer obeyed its local budget. The system as a whole multiplied cost because no layer emitted a stop reason the next layer respected.

Implementation pattern: a stop ledger

A stop ledger is a small durable record written at loop boundaries. It is not a full trace. It is the minimum state needed to resume, suppress unsafe retries, and explain the stop to product code.

from dataclasses import dataclass, field
from enum import StrEnum
from time import time
from typing import Any

class StopReason(StrEnum):
    BUDGET_EXHAUSTED = "budget_exhausted"
    NO_PROGRESS = "no_progress"
    NEEDS_APPROVAL = "needs_approval"
    DEPENDENCY_LIMITED = "dependency_limited"
    INVALID_TOOL_RESULT = "invalid_tool_result"
    POLICY_BLOCKED = "policy_blocked"
    COMPLETED_PARTIAL = "completed_partial"

@dataclass(frozen=True)
class StopRecord:
    run_id: str
    reason: StopReason
    turns_used: int
    turn_budget: int
    cursor: str | None
    recoverability: str
    evidence: dict[str, Any] = field(default_factory=dict)
    recorded_at: float = field(default_factory=time)

def classify_stop(*, turns_used: int, turn_budget: int,
                  repeated_state_count: int,
                  waiting_for_approval: bool,
                  dependency_retry_after: int | None,
                  cursor: str | None) -> StopRecord | None:
    if waiting_for_approval:
        reason = StopReason.NEEDS_APPROVAL
        recoverability = "resume_after_human_decision"
    elif dependency_retry_after is not None:
        reason = StopReason.DEPENDENCY_LIMITED
        recoverability = f"retry_after_{dependency_retry_after}s"
    elif repeated_state_count >= 3:
        reason = StopReason.NO_PROGRESS
        recoverability = "resume_after_code_or_input_change"
    elif turns_used >= turn_budget:
        reason = StopReason.BUDGET_EXHAUSTED
        recoverability = "resume_with_larger_budget_or_smaller_task"
    else:
        return None

    return StopRecord(
        run_id="support_refund_742",
        reason=reason,
        turns_used=turns_used,
        turn_budget=turn_budget,
        cursor=cursor,
        recoverability=recoverability,
        evidence={"repeated_state_count": repeated_state_count},
    )

This example is intentionally boring. The power is not in the enum. The power is in making the loop record a durable cursor and a reason before control leaves the runtime. A queue worker can decide not to retry POLICY_BLOCKED. A UI can render NEEDS_APPROVAL as a review task. A scheduler can delay DEPENDENCY_LIMITED according to the dependency window. An evaluator can count NO_PROGRESS separately from genuine model refusals.

The ledger should be append-only for audit and compact enough for product systems to read. The full trace can include prompts, tool inputs, model spans, and provider metadata. The stop ledger should include the classification, budget consumption, resume cursor, and public-safe evidence.

Tradeoffs and failure modes

The first failure mode is over-classification. Teams create twenty stop reasons and every service interprets them differently. Start with five to eight reasons and require a concrete automation difference for each one. If tool_timeout and provider_timeout both lead to the same retry queue, they may be trace attributes under dependency_limited, not separate public reasons.

The second failure mode is leaking internals. A stop reason is a contract, not a stack trace. Do not expose prompt names, private policy IDs, secret tool URLs, or vendor-specific error bodies in public API fields. Put sensitive details in access-controlled traces and return a stable reason to callers.

The third failure mode is retry multiplication. A turn cap, graph recursion limit, Temporal retry policy, Tenacity retry decorator, and Kubernetes Job backoff can all be correct in isolation. Together they can create a retry storm. The outer layer should read the inner stop reason before spending another attempt.

The fourth failure mode is treating stop reasons as observability only. A dashboard label helps humans, but production loops need machine-readable semantics. If the platform emits loop.stop but the API still returns a generic 500, the runtime has not actually improved recovery.

The best test is a table-driven harness. Feed the loop repeated invalid tool outputs, dependency rate limits, approval pauses, ambiguous user input, and genuine budget exhaustion. Assert not only that the loop stops, but that it stops with the right reason, durable cursor, and allowed next action.

The operating decision

The engineering decision is not whether to set max_turns or recursion_limit. You should set them. The decision is whether those limits are dead ends or part of a recovery contract.

A production agent loop should be able to say: I stopped, here is the reason, here is the budget I consumed, here is whether progress was made, here is whether resumption is safe, and here is the next action automation may take. That is the difference between a demo loop and an operating system for agent work.

FAQ

Do typed stop reasons replace max_turns or recursion_limit?

No. They sit above those controls. The OpenAI Agents SDK and LangGraph show why hard caps are useful safety primitives. Stop reasons explain what the runtime learned when a cap or condition fired.

Should stop reasons be in traces or API responses?

Both, with different detail levels. The API should return a stable low-cardinality reason. The trace can attach detailed attributes to a loop.stop event using OpenTelemetry span events.

How many stop reasons are enough?

Use the smallest set that changes automation. If two cases have the same retry, resume, alert, and user-facing behavior, keep one public reason and put the difference in trace attributes.

How do we test stop reasons?

Use table-driven scenarios that force each stop path. Include repeated states for no_progress, dependency retry windows for dependency_limited, approval pauses for needs_approval, and exhausted budgets for budget_exhausted. The test should assert the reason, cursor, and next action, not only the exception type.

References

  1. OpenAI Agents SDK: Running agents
  2. OpenAI Agents SDK: MaxTurnsExceeded source
  3. OpenAI Agents SDK: runner source
  4. LangGraph: GRAPH_RECURSION_LIMIT
  5. LangGraph: GraphRecursionError source
  6. Temporal: Retry Policies
  7. Kubernetes: Jobs
  8. Tenacity
  9. OpenTelemetry Trace API: Add events