Agent Loop Production Hardening: Circuit Breakers, Token Budgets, and Graceful Degradation

An engineering deep dive on production hardening for autonomous agent loops — token-aware rate limiting, circuit breaker state machines, budget-aware runtimes, and empirical failure data from production deployments of LLM agents.

The defining production challenge of LLM agent systems in 2026 is not making agents smarter — it’s making them safe to run unsupervised. Every production agent deployment eventually faces the same failure modes: runaway loops that burn through API budgets in minutes, tools called with invalid arguments that cascade into corrupted state, and degradation patterns that silently eat latency SLAs.

This post maps the engineering patterns that separate production agent systems from prototypes: token-aware rate limiting, circuit breaker state machines, budget-aware runtimes, and graceful degradation strategies backed by real production data from industry deployments.

The Failure Data: What Actually Goes Wrong

A June 2026 analysis of production agent incidents across 23 deployments by Truefoundry found three dominant failure categories 1:

Failure Mode Frequency Avg Cost per Incident Recovery Time
Runaway loops (retry storms) 37% $47.32 8.3 min
Token budget exhaustion mid-task 28% $12.15 2.1 min
Tool call cascading failures 22% $23.80 15.7 min
Context window overflow 13% $3.40 0.8 min

Runaway loops dominate because they compound: a single hallucinated tool call triggers a retry, which fails again, which triggers another retry, each consuming full context windows. One documented case in the study shows an agent generating 46 tool calls in 12 seconds before manual intervention — a $240 bill for a task that should have cost $0.15 1.

Pattern 1: Token-Aware Rate Limiting

Simple request-count rate limiting is insufficient for agent systems because LLM API costs are proportional to token volume, not request count. A single tool call retry with full conversation history attached can cost 50x more than the initial call.

import time
import asyncio
from collections import defaultdict
from dataclasses import dataclass, field

@dataclass
class TokenBucket:
    """Token-aware rate limiter that accounts for input + output tokens."""
    max_tokens_per_minute: int = 500_000
    max_tokens_per_request: int = 64_000
    refill_rate_tokens_per_sec: int = 8_333  # 500K / 60
    
    tokens_remaining: float = 500_000.0
    last_refill: float = field(default_factory=time.monotonic)
    
    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        self.tokens_remaining = min(
            self.max_tokens_per_minute,
            self.tokens_remaining + elapsed * self.refill_rate_tokens_per_sec
        )
        self.last_refill = now
    
    def try_consume(self, input_tokens: int, output_tokens: int = 4_096) -> tuple[bool, float]:
        """Returns (accepted, wait_time_seconds)."""
        self._refill()
        total = input_tokens + output_tokens
        
        if total > self.max_tokens_per_request:
            return False, float('inf')
        
        if total <= self.tokens_remaining:
            self.tokens_remaining -= total
            return True, 0.0
        
        deficit = total - self.tokens_remaining
        wait_time = deficit / self.refill_rate_tokens_per_sec
        return False, wait_time


class TokenAwareAgentRuntime:
    """Agent loop with token-aware rate limiting and request budgeting."""
    
    def __init__(self, budget_tokens: int = 2_000_000):
        self.bucket = TokenBucket()
        self.budget_remaining = budget_tokens
        self.loop_count = 0
        self.max_loops = 25
    
    async def run(self, task: str) -> str:
        while self.loop_count < self.max_loops:
            self.loop_count += 1
            
            # Estimate tokens for next iteration
            input_tokens = self._estimate_input()
            accepted, wait = self.bucket.try_consume(input_tokens)
            
            if not accepted:
                if wait == float('inf'):
                    return self._degrade("Request exceeds max token limit")
                await asyncio.sleep(wait)
                continue
            
            if input_tokens > self.budget_remaining:
                return self._degrade(f"Token budget exhausted after {self.loop_count} iterations")
            
            # Make the LLM call
            result = await self._llm_call(task, input_tokens)
            self.budget_remaining -= input_tokens
            
            if result["done"]:
                return result["output"]
        
        return self._degrade(f"Max loop count ({self.max_loops}) exceeded")
    
    def _estimate_input(self) -> int:
        # Estimate tokens from accumulated conversation history
        return len(self._messages) * 4  # rough 4 chars per token
    
    def _degrade(self, reason: str) -> str:
        return f"[DEGRADED] {reason} — partial results returned"

The Truefoundry team documented that token-aware rate limiting reduced cost overruns by 78% compared to request-count-only limiting in their agent gateway implementation 2. The key insight: track input tokens separately from output tokens — a single retry with full history attached is the most common budget killer.

Pattern 2: Circuit Breaker State Machine for Agent Loops

A circuit breaker for agent loops needs more states than the classic three (closed/open/half-open). Agent failures are rarely binary — they decay gradually as context windows fill and model confidence degrades.

from enum import Enum
import time
from collections import deque

class CircuitState(Enum):
    CLOSED = "closed"              # Normal operation
    WATCHING = "watching"          # Elevated failure rate, monitoring
    DEGRADED = "degraded"          # Reduced functionality mode
    OPEN = "open"                  # Blocked — human intervention needed

class AgentCircuitBreaker:
    """Four-state circuit breaker for agent loops.
    
    States:
    - CLOSED: Normal operation, no limits
    - WATCHING: Failure rate elevated (>10%), monitoring closely
    - DEGRADED: High failure rate (>30%), reduced max iterations
    - OPEN: Critical failure rate (>50%), agent disabled
    """
    
    def __init__(self, window_seconds: int = 300, max_failures: int = 5):
        self.state = CircuitState.CLOSED
        self.failures: deque[float] = deque(maxlen=500)
        self.total_calls = 0
        self.window = window_seconds
        self.state_changed_at = time.monotonic()
        self.failure_thresholds = {
            CircuitState.CLOSED: 0.10,
            CircuitState.WATCHING: 0.30,
            CircuitState.DEGRADED: 0.50,
        }
        # State-dependent agent parameters
        self.max_iterations = {
            CircuitState.CLOSED: 25,
            CircuitState.WATCHING: 15,
            CircuitState.DEGRADED: 8,
            CircuitState.OPEN: 0,
        }
    
    def record_call(self, success: bool):
        """Record an LLM call result and potentially transition state."""
        now = time.monotonic()
        self.total_calls += 1
        if not success:
            self.failures.append(now)
        
        # Prune old failures outside window
        while self.failures and self.failures[0] < now - self.window:
            self.failures.popleft()
        
        self._maybe_transition(now)
    
    def _maybe_transition(self, now: float):
        """Evaluate and apply state transitions."""
        rate = self.failure_rate()
        current = self.state
        
        if current == CircuitState.CLOSED and rate > self.failure_thresholds[CircuitState.CLOSED]:
            self.state = CircuitState.WATCHING
            self.state_changed_at = now
        elif current == CircuitState.WATCHING and rate > self.failure_thresholds[CircuitState.WATCHING]:
            self.state = CircuitState.DEGRADED
            self.state_changed_at = now
        elif current == CircuitState.DEGRADED and rate > self.failure_thresholds[CircuitState.DEGRADED]:
            self.state = CircuitState.OPEN
            self.state_changed_at = now
            self._alert("Agent circuit OPEN — manual intervention required")
        elif current in (CircuitState.WATCHING, CircuitState.DEGRADED) and rate < 0.05:
            # Recovery: sustained low failure rate
            self.state = CircuitState.CLOSED
            self.state_changed_at = now
    
    def failure_rate(self) -> float:
        if not self.total_calls:
            return 0.0
        window_failures = len(self.failures)
        return window_failures / max(self.total_calls, 1)
    
    def allowed_iterations(self) -> int:
        return self.max_iterations[self.state]

This four-state model is adapted from production patterns documented in the Zylos Research March 2026 analysis of graceful degradation in agent systems 3. Their finding: a binary open/closed breaker triggers too late for agent workloads — by the time the failure rate hits 50%, the damage (cost, corrupted state) is already done. The intermediate WATCHING and DEGRADED states allow preemptive intervention: reducing max iterations, switching to cheaper models, or falling back to simpler tool sets.

Pattern 3: Budget-Aware Runtime with Enforceable Limits

The most effective production pattern documented across multiple deployments is the budget-aware runtime — a layer that tracks token consumption, wall-clock time, and tool call count simultaneously, enforcing the strictest limit 4.

@dataclass
class Budget:
    """Multi-dimensional budget for an agent run."""
    max_tokens: int = 500_000
    max_time_seconds: int = 120
    max_tool_calls: int = 30
    max_cost_usd: float = 0.50

class BudgetAwareRuntime:
    """Runtime that enforces budget across 4 dimensions simultaneously."""
    
    def __init__(self, budget: Budget):
        self.budget = budget
        self.tokens_used = 0
        self.tool_calls_made = 0
        self.cost_incurred = 0.0
        self.start_time = time.monotonic()
        self.call_log: list[dict] = []
    
    def check(self) -> tuple[bool, str]:
        """Returns (allowed, reason_if_blocked)."""
        elapsed = time.monotonic() - self.start_time
        
        checks = [
            (self.tokens_used >= self.budget.max_tokens,
             f"Token budget exhausted ({self.tokens_used}/{self.budget.max_tokens})"),
            (elapsed >= self.budget.max_time_seconds,
             f"Time budget exhausted ({elapsed:.0f}s/{self.budget.max_time_seconds}s)"),
            (self.tool_calls_made >= self.budget.max_tool_calls,
             f"Tool call limit reached ({self.tool_calls_made}/{self.budget.max_tool_calls})"),
            (self.cost_incurred >= self.budget.max_cost_usd,
             f"Cost budget exhausted (${self.cost_incurred:.2f}/${self.budget.max_cost_usd:.2f})"),
        ]
        
        for blocked, reason in checks:
            if blocked:
                return False, reason
        
        return True, "ok"
    
    def record_llm_call(self, input_tokens: int, output_tokens: int, cost: float):
        self.tokens_used += input_tokens + output_tokens
        self.cost_incurred += cost
        self.call_log.append({
            "type": "llm",
            "tokens": input_tokens + output_tokens,
            "cost": cost,
            "timestamp": time.monotonic(),
        })
    
    def record_tool_call(self, tool_name: str):
        self.tool_calls_made += 1
        self.call_log.append({
            "type": "tool",
            "tool": tool_name,
            "timestamp": time.monotonic(),
        })

The key engineering insight from the Reddit production engineering discussion 4 and the Truefoundry blog 2: enforce all four limits simultaneously. Token-only budgets miss runaway time (an agent stuck in a loop generates few tokens but takes forever). Time-only budgets miss cost explosions (a single expensive model call can exceed budget in one shot). The budget check must occur before every LLM call and every tool invocation — checking only at the start of a loop iteration misses mid-tool-call cost.

async def safe_agent_loop(runtime: BudgetAwareRuntime, task: str) -> str:
    """Agent loop with per-call budget enforcement."""
    messages = [{"role": "user", "content": task}]
    
    while True:
        # CHECK BEFORE LLM CALL
        allowed, reason = runtime.check()
        if not allowed:
            return f"[BUDGET_EXCEEDED] {reason}"
        
        # Make LLM call
        response = await llm_call(messages)
        runtime.record_llm_call(
            input_tokens=response.input_tokens,
            output_tokens=response.output_tokens,
            cost=response.cost,
        )
        
        if response.tool_calls:
            for tool_call in response.tool_calls:
                # CHECK BEFORE TOOL CALL
                allowed, reason = runtime.check()
                if not allowed:
                    return f"[BUDGET_EXCEEDED] {reason}"
                
                result = await execute_tool(tool_call)
                runtime.record_tool_call(tool_call.name)
                messages.append(tool_call.to_message(result))
        else:
            return response.content

Pattern 4: Graceful Degradation Strategies

When budgets are exhausted or circuits open, the agent should degrade — not crash. Four patterns for graceful degradation in agent systems, ordered by severity and adapted from Zylos Research and Truefoundry analyses 3:

Strategy What Changes When to Apply
Model downgrade Switch from flagship model (Claude Sonnet 4.6) to cheaper model (Haiku 4.5) WATCHING state, or when token budget is 30%+ consumed
Tool set reduction Remove expensive tools (web search, code execution) from available tool list DEGRADED state, or when tool call budget is 50%+ consumed
Max iterations reduction Reduce from 25 to 8 iterations, forcing earlier partial-result submission DEGRADED state
Human handoff Stop making decisions, return partial state + request human input OPEN state, or when all budgets exhausted
class DegradationStrategy:
    """Progressive degradation based on circuit state and budget remaining."""
    
    def get_config(self, circuit: AgentCircuitBreaker, runtime: BudgetAwareRuntime) -> dict:
        config = {
            "model": "claude-sonnet-4.6",
            "max_iterations": 25,
            "available_tools": ["read_file", "write_file", "search", "execute"],
            "partial_result_ok": False,
        }
        
        state = circuit.state
        budget_frac = runtime.tokens_used / max(runtime.budget.max_tokens, 1)
        
        if state == CircuitState.WATCHING or budget_frac > 0.3:
            config["model"] = "claude-haiku-4.5"
            config["partial_result_ok"] = True
        
        if state == CircuitState.DEGRADED or budget_frac > 0.5:
            config["max_iterations"] = 8
            config["available_tools"] = ["read_file", "write_file"]
        
        if state == CircuitState.OPEN or budget_frac > 0.8:
            config["max_iterations"] = 1
            config["available_tools"] = []
            config["partial_result_ok"] = True
            config["human_handoff"] = True
        
        return config

Putting It Together: Hardened Agent Runtime

class HardenedAgentRuntime:
    """Production agent runtime combining circuit breaker, budget enforcement,
    token-aware rate limiting, and graceful degradation."""
    
    def __init__(self, task: str):
        self.task = task
        self.circuit = AgentCircuitBreaker()
        self.budget = BudgetAwareRuntime(Budget())
        self.rate_limiter = TokenBucket()
        self.degrader = DegradationStrategy()
    
    async def run(self) -> str:
        config = self.degrader.get_config(self.circuit, self.budget)
        messages = [{"role": "user", "content": self.task}]
        
        for iteration in range(config["max_iterations"]):
            # Gate 1: Circuit breaker
            if self.circuit.allowed_iterations() <= iteration:
                return self._handoff("Circuit breaker OPEN")
            
            # Gate 2: Budget check
            allowed, reason = self.budget.check()
            if not allowed:
                return self._handoff(reason)
            
            # Gate 3: Rate limiter
            input_tokens = estimate_tokens(messages)
            accepted, wait = self.rate_limiter.try_consume(input_tokens)
            if not accepted:
                if wait == float('inf'):
                    return self._handoff("Request exceeds rate limit")
                await asyncio.sleep(wait)
            
            # Execute
            try:
                response = await llm_call(messages, model=config["model"])
                self.budget.record_llm_call(
                    response.input_tokens, response.output_tokens, response.cost
                )
                self.circuit.record_call(success=True)
            except Exception as e:
                self.circuit.record_call(success=False)
                if self.circuit.state == CircuitState.OPEN:
                    return self._handoff(f"API failure: {e}")
                continue  # retry with updated config
            
            # Process tool calls
            for tool_call in response.tool_calls:
                if tool_call.name not in config["available_tools"]:
                    continue  # silently skip tools not in degraded set
                
                allowed, reason = self.budget.check()
                if not allowed:
                    return self._handoff(reason)
                
                try:
                    result = await execute_tool(tool_call)
                    self.budget.record_tool_call(tool_call.name)
                    self.circuit.record_call(success=True)
                except Exception:
                    self.circuit.record_call(success=False)
                
                messages.append(tool_call.to_message(result))
        
        # Completed normally or hit iteration limit
        return self._finalize(messages, config)

Production Telemetry: What to Monitor

Based on production deployment data from Truefoundry, Zuplo, and Zylos Research 15, track these metrics per agent run:

  1. Budget utilization ratio — (tokens used / budget). Alert at 0.8 for preemptive action.
  2. Circuit state transitions — Count of WATCHING/DEGRADED/OPEN transitions. Frequent transitions indicate systemic issues.
  3. Degradation invocations — How often does the system degrade rather than complete? Above 15% degradation rate indicates insufficient budgets.
  4. False positive circuit opens — Circuits that opened but the agent was operating correctly. Too many false opens mean adjust thresholds.
  5. Cost variance — (actual cost / expected cost) per task type. High variance indicates looping or retry storms.

Key Takeaways

  1. Request-count rate limiting is insufficient for agent workloads. Token-aware rate limiting that accounts for accumulating conversation history prevents 78% of cost overruns 2.

  2. Use a four-state circuit breaker (CLOSED → WATCHING → DEGRADED → OPEN) rather than binary open/closed. Agent failures decay gradually — preemptive intervention beats post-hoc recovery.

  3. Enforce budgets on all four dimensions simultaneously: tokens, time, tool calls, cost. Single-dimension budgets always leak.

  4. Graceful degradation is a feature, not a fallback. A degraded agent that returns partial results with correct metadata is infinitely more useful than one that crashes silently after burning $50 on retries.

  5. Instrument everything. The production telemetry data from monitoring budget utilization and circuit state is the single highest-leverage investment for improving agent reliability.

The engineering lesson from every production agent incident in 2026 is the same: the agent doesn’t need to be smarter — the runtime needs to be safer. These patterns convert that lesson into code.

References