Back to Blog
A red motor stop button on a metal control panel, representing a circuit breaker that halts a runaway agent loop

AI Agent Circuit Breakers: Stopping Runaway Tool Loops

9 min read

A tool-using agent can burn through model calls by repeating the same search, bouncing between two specialists, or retrying a business action whose result it misread. A maximum-turn setting ends the run eventually. It does not protect each side effect, notice a lack of progress, or leave an operator enough evidence to resume safely. An agent circuit breaker enforces several budgets, detects repeated states, and interrupts the run before the next external mutation. This guide defines that controller, its state model, a build order, and tests.

Why a maximum-turn limit is not enough

Most agent runtimes cycle through the same steps: ask the model, execute requested tools, append results, ask again. The OpenAI Agents SDK describes this run loop and raises MaxTurnsExceeded past a configured limit. LangGraph applies a recursion limit when a graph takes too many steps without reaching a stop condition. Both are useful. Neither can tell whether step seven is progress or the seventh copy of the same failed action.

One limit also merges different risks into one number. Twenty read-only catalog searches may be fine. Two attempts to submit the same invoice may not be. A run can stay under its turn limit while blowing its cost budget, its deadline, or its allowed number of writes. It can also hit the turn limit right after a tool completed a side effect, leaving the workflow unable to say whether another attempt would duplicate it.

Treat maximum turns as the final backstop, and put a progress-aware controller around each model and tool transition so the system stops at the risk boundary.

Rule out the loop you caused yourself

Before building detectors, check whether your own configuration forces the loop. If tool_choice is set to required or to a named tool and stays that way after the tool returns, the model is told to call a tool again on every turn, and it will. The OpenAI Agents SDK documents this failure and resets tool_choice to auto after a tool call by default, with a reset_tool_choice setting that brings the loop back if you turn it off. If you call a provider API directly, check whether anything in your code resets it. A breaker that trips on this loop reports a symptom, and the fix is one line of request configuration.

The reverse mistake is also common. Setting tool_choice to none after any tool result stops the loop and breaks every task that needs a second step.

Define terminal states before detecting loops

The controller needs to know which outcomes end normally, which can be retried automatically, and which need a person. Use a small terminal-state set:

  • completed: the required output exists and all mandatory postconditions pass.
  • rejected: policy or business rules deny the request.
  • needs_input: required information is missing and the agent has a specific question.
  • interrupted: a budget or progress rule stopped execution.
  • failed: an unrecoverable platform or tool error ended the run.

Validate completion against deterministic postconditions, not free-form model text. A ticket-creation workflow is complete only when it has a stable ticket ID, the expected project, and the requested fields. A sentence saying the ticket was created is not evidence.

Apply the same rule to missing input. The agent names the missing field and shows why it cannot proceed, or needs_input becomes another route for vague retries.

Give the run independent execution budgets

Create a budget object when the workflow starts. Keep each dimension separate because each controls a different failure mode.

@dataclass
class RunBudget:
    max_turns: int
    deadline_at: datetime
    max_model_cost_usd: Decimal
    max_tool_calls: int
    max_side_effects: int
    max_same_intent: int
    max_no_progress_steps: int

@dataclass
class RunCounters:
    turns: int = 0
    tool_calls: int = 0
    side_effects: int = 0
    model_cost_usd: Decimal = Decimal("0")
    no_progress_steps: int = 0

Check the deadline and remaining budget before every model request and tool call. Reserve estimated cost before calling a model, then reconcile against actual usage when the provider returns it. Reserve a side-effect slot before execution, not afterward. If no slot remains, the tool must not run.

The side-effect count should reflect business mutations, not HTTP requests. A payment API retry with one idempotency key is one intended mutation, although it may involve several transport attempts. A second payment intent is another side effect. Store that distinction in the tool adapter rather than asking the model to infer it.

Budget values should come from the workflow type and risk class. A read-only research agent can have a larger turn allowance than an accounts-payable agent. OWASP's excessive-agency guidance recommends limiting functionality, permissions, and autonomy, with human approval for high-impact actions. The budget is one enforcement layer for that policy, not a replacement for downstream authorization.

Detect progress from state changes

A loop detector needs a compact fingerprint of what the agent intended, what the tool observed, and what business state changed. Raw prompt comparison is too brittle because the model can rephrase the same request on every turn.

Build an intent fingerprint from normalized fields:

intent_fingerprint = hash(
  tool_name,
  trusted_tenant_id,
  normalized_resource_id,
  normalized_operation,
  canonical_arguments_without_nonce
)

outcome_fingerprint = hash(
  tool_status,
  stable_result_identifiers,
  normalized_error_class,
  business_version_after_call
)

Exclude timestamps, request IDs, tracing IDs, and generated wording. Those values change even when the agent makes no progress. Include tenant, resource, operation, and stable business version because those values distinguish actions that only look similar.

After each step, compare the new fingerprints with recent history. Increment no_progress_steps when any of these conditions holds:

  1. The same intent produces the same outcome.
  2. Two or more agents hand the task back without changing required fields or business state.
  3. The model requests a write that the policy layer already denied for the same reason.
  4. A read tool returns the same resource version and the plan does not change.
  5. The agent alternates between a small set of intents without satisfying a postcondition.

Reset the counter only when a named postcondition advances. More tokens, a longer plan, or different wording do not count.

Count what the loop actually spends

Turns are a poor unit. A long research turn can fit inside the turn cap and still fail because every tool result stays in the transcript and the prompt grows until the provider rejects it, and one turn's cost depends on how much it reads. Anthropic's multi-agent research write-up puts numbers on the spread: agents use about 4 times the tokens of a chat interaction and multi-agent systems about 15 times, and token usage alone explained 80% of the variance in their BrowseComp results. Budget in tokens and dollars per run, check the last prompt's size on every iteration, and when it passes a fixed ceiling treat the next iteration as the final one and tell the model so. The person gets an answer from what was read instead of a provider error. Announcing the last iteration matters: a model that does not know it is the last one spends it looking things up.

The failures that need a breaker are also specific. The same write-up lists early agent behavior: spawning 50 subagents for simple queries, scouring the web endlessly for nonexistent sources, and distracting each other with excessive updates. Anthropic's fix was effort-scaling rules in the prompt, such as one agent with 3 to 10 tool calls for simple fact-finding and more than 10 subagents only for complex research. Prompt rules like those are guidance, and a model can ignore them, so mirror each tier as a hard cap in code. Set the cap per task class instead of one global number, because a cap tuned by fear makes ordinary multi-step requests end in an apology, while a cap that is too large makes a stuck loop expensive. Deterministic guards, such as refusing an identical search the agent has already run, catch stuck cases better than any number.

Identical failures get a staged response. The first repeat of the same failing call with the same error gets one nudge telling the model not to send it unchanged. The third identical failure ends the turn. That rule fires long before any turn limit and costs nothing to run.

Stop before the next mutation

Place the circuit-breaker decision between proposal and execution. The sequence should be model proposal, argument validation, authorization, budget reservation, progress check, approval when required, and tool execution. Checking after execution is too late for duplicate emails, payments, calendar events, or permission changes.

A practical controller can return one of four decisions:

class Decision(Enum):
    ALLOW = "allow"
    ALLOW_READ_ONLY = "allow_read_only"
    INTERRUPT = "interrupt"
    REQUIRE_APPROVAL = "require_approval"

def decide(run, proposed_call):
    if run.deadline_expired() or run.model_budget_exhausted():
        return Decision.INTERRUPT
    if proposed_call.is_mutation and run.side_effect_budget_exhausted():
        return Decision.INTERRUPT
    if run.same_intent_count(proposed_call) >= run.budget.max_same_intent:
        return Decision.INTERRUPT
    if run.no_progress_steps >= run.budget.max_no_progress_steps:
        return Decision.ALLOW_READ_ONLY if proposed_call.can_diagnose else Decision.INTERRUPT
    if proposed_call.requires_human_approval:
        return Decision.REQUIRE_APPROVAL
    return Decision.ALLOW

The optional read-only allowance helps the agent collect one final diagnostic fact without permitting another mutation. Keep it narrow. It should call an approved status endpoint, not trigger a broad new planning cycle.

Keep the outer runtime limit. The OpenAI run API exposes configurable turn limits and error handling, and LangGraph's recursion limit catches graph-level cycles. Leave those hard stops above the controller in case a bug bypasses your progress logic.

Persist an interruption record

Store a typed record before releasing the worker. An interruption is an operational outcome, and an exception string loses what an operator needs:

{
  "run_id": "run_0187",
  "state": "interrupted",
  "reason": "repeated_intent_same_outcome",
  "budget_snapshot": {
    "turns": 8,
    "tool_calls": 6,
    "side_effects": 1,
    "model_cost_usd": "0.42"
  },
  "last_intent": "update_crm_contact",
  "last_stable_resource_id": "contact_42",
  "business_version": "17",
  "side_effect_status": "confirmed",
  "safe_next_actions": ["inspect", "resume_read_only", "close"],
  "resume_token_version": 3
}

Store references to protected payloads rather than copying secrets or personal data into the record. Include the last confirmed business version and side-effect status. If the tool response was lost after a request left your system, mark the side effect unknown and force reconciliation against the provider before resume.

Durable workflow systems separate workflow failure, activity failure, timeout, and retry context. Temporal's Python error-handling guidance shows why typed failures matter, since retry behavior depends on what failed and how the failure is represented. The same holds if your orchestrator is a queue and a database.

Resume with a new decision, not the old loop

Never resume by replaying the last model output. Reload trusted business state, verify the workflow version, and create a new execution decision.

Require these checks:

  • The operator is authorized for the tenant and action.
  • The workflow definition and policy versions are compatible with the saved checkpoint.
  • Every previous mutation is classified as confirmed, rejected, or unknown.
  • Unknown mutations are reconciled through a provider status or idempotency lookup.
  • Remaining budgets are recalculated instead of reset automatically.
  • Any approved exception names the exact action, resource version, expiry, and allowed attempt count.

If the operator changes the inputs, start a new run linked to the interrupted one. This keeps the audit trail honest and avoids treating a materially different request as continuation.

Test the controller with hostile and ordinary runs

Unit tests should cover each decision rule, but the useful failures appear in complete runs. Build fixtures that drive the real model-tool loop through these cases:

  1. A search tool returns the same empty result under three rephrased queries.
  2. Two specialist agents hand off to each other without changing state.
  3. A write succeeds, its response times out, and the agent proposes the write again.
  4. A policy denial is rephrased and submitted repeatedly.
  5. A legitimate long-running investigation makes measurable progress for more turns than the usual limit.
  6. Model cost reaches its budget before the turn limit.
  7. The deadline expires while a tool is running.
  8. An operator resumes after the business record changed.

Assert the negative behavior: no tool mutation occurs after interruption, no budget can go below zero, and no unknown side effect is retried without reconciliation. Also assert useful behavior. A long run with changing resource versions and advancing postconditions should not trip the no-progress breaker.

Track interruption reason, workflow type, tool, and whether an operator resumed or closed the run. A rising repeated_intent_same_outcome rate usually indicates poor tool-result semantics or an unreachable terminal condition. A rising cost-budget rate may require a cheaper model path or a smaller task. Do not respond by raising every limit globally.

Implementation checklist

Start with one side-effecting workflow and implement the boundary in this order:

  • Define deterministic completion and failure postconditions.
  • Classify every tool as read-only or side-effecting.
  • Add turn, deadline, cost, tool-call, and side-effect budgets.
  • Create stable intent and outcome fingerprints.
  • Detect repeated outcomes and cyclic handoffs.
  • Run the breaker before authorization is converted into execution.
  • Persist a typed interruption record with side-effect certainty.
  • Add reconciliation and version checks to resume.
  • Test repeated writes, lost responses, stale resumes, and legitimate long runs.
  • Keep the framework's hard turn or recursion limit as a final backstop.
  • Check that nothing in your own request configuration forces another tool call.

Apply it to the workflow with the most expensive or consequential repeated action. Run the eight end-to-end cases and read every interruption record before enabling automatic resume.

References

  1. OpenAI Agents SDK running agents supports the description of the model-tool loop and maximum-turn failure.
  2. LangGraph recursion-limit error supports the graph-level limit and infinite-loop failure mode.
  3. OpenAI Agents SDK agents documents reset_tool_choice as a guard against tool-use loops when a tool choice is forced.
  4. OWASP LLM06:2025 Excessive Agency supports limiting functionality, permissions, autonomy, and high-impact actions.
  5. OpenAI Agents SDK run API supports the current turn-limit and error-handler configuration.
  6. Temporal Python error handling supports typed failure, retry, and timeout handling in durable workflows.
  7. Anthropic, multi-agent research system engineering post supports the token multipliers, the early runaway-agent behaviors and the effort-scaling rules.

About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build AI workflow automation, internal tools, and custom software with the execution budgets and circuit breakers described above, at published fixed prices. Schedule a call to discuss your next project.