Loop Budgets Need Phase Ledgers

Sep 23 2026 · 10 min · Sieon

A loop should spend another turn only when it can name the phase budget it is consuming. A max-turn cap catches runaway execution, but it does not tell you whether the loop wasted time in model calls, tools, retries, handoffs, or waits. I treat each phase as an accountable ledger entry.

The decision rule: spend another turn only against a named phase budget

The rule I use is simple: no unnamed spend. If an agent loop wants another iteration, it must first write a budget receipt with a phase, a reason, a remaining allowance, and the next stop condition. That is more operational than setting one large cap and hoping the runtime fails safely.

The reason is visible in current agent runtimes. The OpenAI Agents SDK documents Runner.run as a loop that can continue through final-output checks, handoffs, tool calls, and another model turn, with max_turns raising MaxTurnsExceeded when the cap is exceeded unless handled in the application (OpenAI Agents SDK Runner reference). LangGraph documents GRAPH_RECURSION_LIMIT as the error raised when a graph reaches the maximum number of steps before a stop condition, often because a cycle never terminates (LangGraph recursion-limit docs). AutoGen exposes termination conditions for tokens, timeout, handoff, text match, function calls, stop messages, source match, and external control (AutoGen termination docs).

Those controls are necessary. They are not enough. They answer "when must execution stop?" They do not answer "which part of the loop spent the budget, and should that phase be allowed to spend again?"

For senior AI engineering teams, the decision rule becomes:

Before the next iteration, classify the spend phase.
If no phase budget remains, stop with that phase as the stop reason.
If the phase budget remains but no new state changed, stop as no progress.
Only then allow the next model, tool, retry, handoff, or wait step.

That rule turns a loop from a hidden while-statement into an auditable control surface. The loop can still be adaptive, but every adaptation carries a cost center.

Why one cap hides the failure mode

A single loop cap compresses several failure modes into one number. If a run stops after 30 turns, you still need to know what those turns were. Ten model calls with no tool execution means a planning problem. Two model calls and twenty failed tool retries means a recovery problem. One handoff repeated across agents means a routing problem. A long wait against an external system means a scheduling problem.

Distributed systems already learned this lesson. Temporal retry policies use named fields such as initial interval, backoff coefficient, maximum interval, maximum attempts, and non-retryable errors, with default maximum attempts documented as unlimited (Temporal retry policy docs). AWS Step Functions separates state failures from recovery paths using Retry and Catch, and its docs distinguish definition problems, task failures, and transient issues as different failure sources (AWS Step Functions error-handling docs). Kubernetes Jobs expose retry and backoff behavior for pods, including controls such as backoffLimit and failure-policy mechanisms around job completion (Kubernetes Job docs).

Agent loops need the same separation. The loop phases are not identical to workflow states or Kubernetes pods, but the reliability lesson is the same: if every failure path increments the same counter, operators lose the shape of the incident.

I split loop spend into five phase budgets:

  1. model: reasoning turns, final-output checks, and self-correction.
  2. tool: external calls, schema repair, tool result interpretation, and permission checks.
  3. retry: repeated work after a failure, including backoff and idempotency checks.
  4. handoff: routing to another agent, human, queue, or workflow.
  5. wait: time spent paused for external state, rate limits, approvals, or asynchronous jobs.

The point is not to invent bureaucracy. The point is to stop treating "one more turn" as a free operation. The next turn might be cheap reasoning, an expensive search, a destructive tool retry, or a human escalation. Those should not share one undifferentiated counter.

A phase ledger schema for agent loops

A phase ledger is a compact record written before the runtime authorizes the next step. It should be small enough to emit on every iteration and stable enough to join with traces later.

A useful entry has these fields:

{
  "run_id": "run_2026_09_23_0800",
  "iteration": 7,
  "phase": "tool",
  "reason": "schema_repair",
  "state_digest": "6ef5c83e2a1f4c91",
  "budget_before": 3,
  "budget_after": 2,
  "delta": "validated customer_id argument",
  "next_stop_reason": "continue",
  "idempotency_key": "tool:get_customer:6ef5c83e"
}

The ledger is not a transcript. It is a control record. LangGraph's persistence docs make a useful distinction: checkpointers persist thread-scoped graph state as checkpoints, while stores persist application-defined data beyond a graph run (LangGraph persistence docs). I use that distinction when placing ledger data. The ledger entry belongs near the execution checkpoint because it decides whether the current loop can continue. Aggregate metrics and tuning history belong in a longer-lived store.

Tool metadata needs a separate warning. The Model Context Protocol tool specification defines tool names, descriptions, input schemas, optional output schemas, and optional annotations, while warning that clients must treat annotations as untrusted unless they come from trusted servers (MCP tools specification). A phase ledger should not blindly trust a tool's self-description when classifying spend. The runtime should own phase classification and policy enforcement.

For observability, emit the same fields into traces. OpenTelemetry publishes Generative AI semantic conventions as the public coordination point for GenAI spans, events, and attributes (OpenTelemetry GenAI semantic conventions). I do not assume there is a standard phase_ledger schema. I do make the ledger fields traceable so an incident review can answer: which phase spent the budget, what state changed, and why did the loop continue?

The invariant is the important part:

ledger_entry.budget_after = ledger_entry.budget_before - approved_spend
ledger_entry.phase in {model, tool, retry, handoff, wait}
ledger_entry.state_digest changes or delta explains why unchanged state is acceptable
ledger_entry.next_stop_reason is explicit before the next iteration starts

If those four conditions are missing, the loop is not governed. It is just capped.

Runnable example: enforce budgets before the next iteration

Here is a minimal Python 3.11+ policy gate. It does not require a framework. The example keeps budgets per phase, rejects unchanged state without a meaningful delta, and returns a stop reason the runtime can surface to operators.

from dataclasses import dataclass
from enum import StrEnum
from hashlib import sha256


class Phase(StrEnum):
    MODEL = "model"
    TOOL = "tool"
    RETRY = "retry"
    HANDOFF = "handoff"
    WAIT = "wait"


class StopReason(StrEnum):
    CONTINUE = "continue"
    SUCCESS = "success"
    NO_PROGRESS = "no-progress"
    PHASE_BUDGET_EXHAUSTED = "phase-budget-exhausted"
    NEEDS_HUMAN = "needs-human"


@dataclass(frozen=True)
class Budget:
    model: int
    tool: int
    retry: int
    handoff: int
    wait: int

    def remaining(self, phase: Phase) -> int:
        return getattr(self, phase.value)

    def spend(self, phase: Phase) -> "Budget":
        if self.remaining(phase) <= 0:
            raise ValueError(f"no remaining budget for {phase.value}")
        values = self.__dict__ | {phase.value: self.remaining(phase) - 1}
        return Budget(**values)


@dataclass(frozen=True)
class PhaseEntry:
    iteration: int
    phase: Phase
    reason: str
    state_digest: str
    delta: str
    budget_before: int
    budget_after: int
    next_stop_reason: StopReason


def digest_state(state: str) -> str:
    return sha256(state.encode("utf-8")).hexdigest()[:16]


def authorize_next(
    *,
    iteration: int,
    phase: Phase,
    reason: str,
    state: str,
    previous_digest: str | None,
    delta: str,
    budget: Budget,
) -> tuple[Budget, PhaseEntry]:
    before = budget.remaining(phase)
    if before <= 0:
        entry = PhaseEntry(
            iteration=iteration,
            phase=phase,
            reason=reason,
            state_digest=digest_state(state),
            delta=delta,
            budget_before=before,
            budget_after=before,
            next_stop_reason=StopReason.PHASE_BUDGET_EXHAUSTED,
        )
        return budget, entry

    current_digest = digest_state(state)
    if previous_digest == current_digest and not delta.strip():
        entry = PhaseEntry(
            iteration=iteration,
            phase=phase,
            reason=reason,
            state_digest=current_digest,
            delta=delta,
            budget_before=before,
            budget_after=before,
            next_stop_reason=StopReason.NO_PROGRESS,
        )
        return budget, entry

    updated = budget.spend(phase)
    entry = PhaseEntry(
        iteration=iteration,
        phase=phase,
        reason=reason,
        state_digest=current_digest,
        delta=delta,
        budget_before=before,
        budget_after=updated.remaining(phase),
        next_stop_reason=StopReason.CONTINUE,
    )
    return updated, entry


if __name__ == "__main__":
    budget = Budget(model=4, tool=3, retry=2, handoff=1, wait=1)
    previous = None
    for i, phase in enumerate([Phase.MODEL, Phase.TOOL, Phase.RETRY], start=1):
        state = f"order checked at phase {phase.value}"
        budget, entry = authorize_next(
            iteration=i,
            phase=phase,
            reason="advance_order_resolution",
            state=state,
            previous_digest=previous,
            delta=f"completed {phase.value} work",
            budget=budget,
        )
        previous = entry.state_digest
        print(entry)

The example is intentionally strict. It stops unchanged state unless the runtime can explain the delta, and it spends the phase budget only after that check. In production, I also attach an idempotency key to tool and retry phases, because a retry that repeats an external side effect is not the same as a retry that repeats a read.

This is where phase ledgers differ from ordinary logs. A log says something happened. A phase ledger decides whether the next thing is allowed to happen.

Operational tradeoffs

The first tradeoff is false stopping. A strict ledger can stop useful work when progress is hard to summarize. That is still better than silent looping. If a team cannot explain progress in one line, the runtime should pause and ask for a better state transition rather than spend another expensive or risky phase.

The second tradeoff is budget granularity. Too few phases recreate the one-cap problem. Too many phases make the policy impossible to tune. I start with the five phases above and split only when incident reviews show repeated ambiguity. For example, tool may later split into read_tool and write_tool if write operations create most of the risk.

The third tradeoff is where to enforce. Framework caps remain valuable. Keep max_turns in an OpenAI Agents SDK runner because it is a final fuse (OpenAI Agents SDK Runner reference). Keep LangGraph recursion limits because a graph can still cycle before your business policy sees clean progress (LangGraph recursion-limit docs). Keep AutoGen termination conditions for token, timeout, handoff, and external stops because they express runtime boundaries (AutoGen termination docs). Add the phase ledger above those controls so the application can explain the spend.

The fourth tradeoff is incident review cost. A ledger adds records. The payoff is that the records are already shaped around the questions reviewers ask: why did this continue, what phase spent the budget, what state changed, and which stop reason was chosen? That is far cheaper than reconstructing a loop from raw chat history and tool output.

My default production policy is conservative:

model: 4 per task objective
tool: 3 per trusted read path, 1 per write path
retry: 2 per idempotent operation, 0 per non-idempotent operation
handoff: 1 before human review
wait: 1 unless an external scheduler owns the delay

Those numbers are not universal. They are intentionally small so the first incident produces evidence instead of a runaway bill. After a week of traces, tune budgets by phase. Do not raise the global cap until you can explain which phase needs room and why.

FAQ

Is max_turns enough for production loops?

No. max_turns is a useful fuse because the OpenAI Agents SDK documents that exceeding it raises MaxTurnsExceeded unless handled (OpenAI Agents SDK Runner reference). It is not a diagnostic model. A phase ledger explains which part of the loop consumed the turns.

Should token limits be part of the same ledger?

Yes, but as a metric on the phase, not as the phase itself. AutoGen documents token-based termination alongside timeout, handoff, text, function-call, source, and external termination conditions (AutoGen termination docs). I record tokens on model and sometimes tool interpretation phases.

How do retries fit without double-counting?

Treat retry as its own phase only when the loop is repeating work after failure. Temporal retry policies make retry behavior explicit with interval, backoff, maximum interval, maximum attempts, and non-retryable errors (Temporal retry policy docs). If a retry also calls a tool, record the retry authorization and the tool spend separately.

What should I trace?

Trace the phase, reason, state digest, budget before and after, next stop reason, and any idempotency key. OpenTelemetry's GenAI semantic conventions are the public alignment point for GenAI telemetry (OpenTelemetry GenAI semantic conventions). Keep the ledger compact, then attach richer artifacts by ID rather than dumping transcripts into spans.

References

  1. Runner – OpenAI Agents SDK
  2. GRAPH_RECURSION_LIMIT – Docs by LangChain
  3. Persistence – Docs by LangChain
  4. Termination – AutoGen
  5. What is a Temporal Retry Policy?
  6. Handling errors in Step Functions workflows – AWS Step Functions
  7. Jobs – Kubernetes
  8. Generative AI semantic conventions – OpenTelemetry
  9. Tools – Model Context Protocol