On this page
- The decision rule: admit the next turn, do not default to it
- Why max-turn caps are not admission control
- The four checks in a loop admission ticket
- A minimal runnable scheduler contract
- Tradeoffs and failure modes
- FAQ
- Does admission control replace max_turns or recursion limits?
- Where should the admission decision live?
- What should teams log?
- When is a loop allowed to continue with no state change?
- References
An agent loop should not receive its next turn by default. The runtime should admit the next turn only when it can show remaining budget, changed state, a safe side-effect boundary, and a named exit condition. Otherwise, the loop is not recovering. It is just spending.
The decision rule: admit the next turn, do not default to it
The strongest loop engineering rule I would put into a production agent runtime is this: every re-entry is an admission decision.
Most teams start with the safer version of an agent loop. They add a turn cap, a graph recursion limit, a timeout, or a retry counter. That is necessary, but it is not enough. The OpenAI Agents SDK describes a runner loop that calls the LLM, checks for final output, follows handoffs, runs tool calls, appends results, and re-runs the loop. If the run exceeds max_turns, the SDK raises MaxTurnsExceeded, and max_turns=None disables that turn limit.
That is a good fuse. It is not an admission controller.
A fuse says the system must stop eventually. An admission controller says whether the next turn is worth spending. Those are different decisions. A loop can be under its max-turn limit and still be unsafe because it is repeating the same invalid tool call. A graph can be under its recursion limit and still be wasteful because no state is changing. A retry can be within policy and still be wrong because the previous attempt already reached an external side effect.
The runtime needs a small control-plane step before every new loop iteration:
- Is there enough budget left for this turn and its likely tool work?
- Did the observed state, input, or recovery action change?
- Is the next action safe with respect to side effects?
- If this turn does not make progress, what stop reason will fire?
If the runtime cannot answer those questions, it should delay, shrink, escalate, or stop. It should not silently ask the model to try again.
Why max-turn caps are not admission control
Hard caps are useful because loops can fail open. LangGraph documents GRAPH_RECURSION_LIMIT as the case where a graph reaches the maximum number of steps before hitting a stop condition. The likely cause is often an infinite loop, although complex graphs can intentionally raise the recursion limit when the design legitimately needs more steps.
AutoGen makes the same operational point from the conversation side. Its termination documentation explains that agent conversations can continue indefinitely unless termination conditions stop them. AutoGen includes termination options around messages, tokens, timeouts, handoffs, source matching, and text matching.
Those controls tell the loop how to die. They do not tell it how to live.
A max-turn cap can stop a runaway loop after ten turns. Admission control tries to prevent turns three through ten from being wasted. It turns the scheduler into a reviewer of loop intent. The scheduler should know which work item is asking for another turn, what changed since the last turn, which budgets remain, and which downstream systems might see repeated side effects.
This mirrors infrastructure scheduling. The Kubernetes scheduler watches for newly created Pods with no assigned node and selects a node for them. The useful analogy is not that agent tasks are Pods. The useful analogy is that scheduling is an explicit decision made from constraints. A Pod is not placed on a node just because it exists. A loop turn should not be placed on the runtime just because the previous turn ended without final output.
The four checks in a loop admission ticket
A practical loop scheduler does not need a heavyweight planner. It needs a compact admission ticket.
First, the ticket records budget. The budget should include turns, tokens, wall-clock time, tool calls, and side-effect attempts when applicable. Temporal retry policies model reliability as explicit policy fields such as initial interval, backoff coefficient, maximum interval, maximum attempts, and non-retryable errors. Agent runtimes need the same explicitness around model turns and tools. A retry that ignores remaining tool budget is not a retry policy. It is a cost leak.
Second, the ticket records change. The runtime should compare a digest of relevant state, not the whole transcript. Did retrieval return different evidence? Did the tool arguments change? Did a human approval arrive? Did the previous model response produce a new plan, or just restate the same one? A loop that cannot point to a changed input or changed recovery action should not be admitted as normal progress.
Third, the ticket records side-effect safety. Repeating a vector search is different from repeating a payment, email, deployment, ticket update, or database write. The scheduler should know whether the previous attempt reached the external system and whether the next call is idempotent. This is where many agent loops become production incidents. They treat every tool failure as if it happened before the boundary, even when the external system may have already acted.
Fourth, the ticket records the next exit. If the next turn is admitted, the scheduler should already know what happens on failure: no_progress, needs_human, dependency_limited, budget_exhausted, or policy_blocked. OpenTelemetry's generative AI semantic conventions give teams a public place to think about model-operation telemetry. The admission decision should become a structured runtime event, not a sentence hidden in a prompt.
A good ticket is intentionally boring. It is not a transcript. It is the answer to one audit question: why was this loop allowed to spend another turn?
A minimal runnable scheduler contract
Here is the smallest standard-library shape I would start with before integrating a specific framework. It treats admission as a pure decision over the previous ticket and the proposed next ticket.
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
from typing import Literal
Decision = Literal["admit", "delay", "escalate", "stop"]
ExitReason = Literal[
"continue",
"no_progress",
"needs_human",
"dependency_limited",
"budget_exhausted",
"side_effect_risk",
]
@dataclass(frozen=True)
class Budget:
turns: int
tool_calls: int
seconds: int
side_effects: int
@dataclass(frozen=True)
class AdmissionTicket:
run_id: str
turn: int
state_digest: str
proposed_action: str
budget: Budget
side_effect_safe: bool
exit_reason_if_blocked: ExitReason
def digest_state(value: str) -> str:
return sha256(value.encode("utf-8")).hexdigest()[:16]
def admit_next_turn(
previous: AdmissionTicket | None,
current: AdmissionTicket,
) -> Decision:
if current.budget.turns <= 0 or current.budget.seconds <= 0:
return "stop"
if current.budget.tool_calls <= 0:
return "escalate"
if not current.side_effect_safe or current.budget.side_effects < 0:
return "escalate"
if previous is None:
return "admit"
changed_state = current.state_digest != previous.state_digest
changed_action = current.proposed_action != previous.proposed_action
spent_budget = current.budget != previous.budget
if changed_state or changed_action or spent_budget:
return "admit"
if current.exit_reason_if_blocked == "dependency_limited":
return "delay"
return "stop"
if __name__ == "__main__":
first = AdmissionTicket(
run_id="run-42",
turn=1,
state_digest=digest_state("search timed out before evidence"),
proposed_action="retry_search_with_shorter_query",
budget=Budget(turns=3, tool_calls=2, seconds=60, side_effects=0),
side_effect_safe=True,
exit_reason_if_blocked="no_progress",
)
second = AdmissionTicket(
run_id="run-42",
turn=2,
state_digest=digest_state("search timed out before evidence"),
proposed_action="retry_search_with_shorter_query",
budget=Budget(turns=2, tool_calls=1, seconds=45, side_effects=0),
side_effect_safe=True,
exit_reason_if_blocked="no_progress",
)
print(admit_next_turn(None, first))
print(admit_next_turn(first, second))
This contract is deliberately stricter than many agent demos. It lets the first turn through, then requires evidence that something changed or a budget was intentionally spent. A production version would attach provider, graph node, tool name, queue, and tenant fields as trace attributes. The important part is ownership: the runtime admits the next turn. The model can propose an action, but it does not grant itself permission to keep looping.
Tradeoffs and failure modes
Admission control adds latency. The scheduler has to compute a digest, read budgets, and sometimes delay work that could have produced a lucky success. That is the price of making loop behavior explainable. If the work is cheap and read-only, the policy can admit aggressively. If the work can send messages, mutate state, or trigger deployments, the policy should be conservative.
The second tradeoff is false stopping. A loop may appear unchanged even though the next model call would have found a better plan. This is why the decision should not be a single equality check against the transcript. Compare the small state that matters: evidence set, tool arguments, approval state, dependency window, remaining budget, and recovery action.
The third failure mode is hiding admission inside prompts. A prompt that says "do not repeat yourself" is not a scheduler. It may help the model reason, but it cannot see all runtime budgets, prior side effects, queue pressure, or tenant policy. Admission control belongs beside the loop runner, graph executor, or workflow worker.
The fourth failure mode is observability without enforcement. Emitting loop.admission events is useful, but the runtime has to obey them. If the trace says no_progress while the outer worker retries the entire request, the system has only moved the problem to another layer. The outer layer should read the admission decision before spending another attempt.
The operating decision is simple: a loop turn is a scheduled resource. Treat it like one. Admit it when the ticket shows budget, changed state, side-effect safety, and a clear exit. Otherwise, stop pretending the loop is thinking and make the recovery path explicit.
The useful team habit is to review admission tickets during incident review. Do not ask only why the model failed. Ask why the runtime admitted the next turn, whether the state digest changed, whether the tool boundary was safe, and whether the blocked exit reason matched the incident. That review loop turns admission control from a defensive feature into a product-quality signal. Teams can tune budgets, identify brittle tools, and retire recovery paths that only create expensive repetition.
This also changes how teams design evaluations. A loop test should not merely assert that the final answer is correct. It should force repeated state, exhausted tool budget, unsafe side effects, and dependency waits, then assert the admission decision. The harness should prove that the scheduler admits useful turns, delays dependency-limited work, escalates unsafe writes, and stops repeated no-progress loops before the outer worker multiplies the cost.
FAQ
Does admission control replace max_turns or recursion limits?
No. OpenAI's max_turns and LangGraph's recursion limit are still necessary fuses. Admission control sits before the next turn and decides whether spending that turn is justified.
Where should the admission decision live?
Put it in the runtime layer that owns scheduling: the runner, graph executor, queue worker, or workflow activity wrapper. The model can suggest the next action, but the runtime should evaluate budgets, side effects, and stop reasons.
What should teams log?
Log the admission decision, run identifier, turn number, state digest, proposed action, remaining budgets, side-effect safety, and blocked exit reason. Use structured telemetry compatible with your tracing stack, and keep private prompts or secrets out of public fields.
When is a loop allowed to continue with no state change?
Only when the scheduler can name a legitimate wait or dependency condition, such as a backoff window, human approval, or external rate limit. Otherwise, unchanged state should produce no_progress, not another automatic model turn.