PydanticAI agent loop prevention: circuit breaker and cost cap

PydanticAI, released by the Pydantic team in late 2024, brings Pydantic’s type-safety philosophy to AI agents: tools are typed Python functions, dependencies are injected via a typed RunContext, and model responses are validated against Pydantic models before reaching your application logic. That type safety catches a whole class of bugs at the schema boundary. It does not prevent the LLM from generating semantically valid but practically useless tool calls in a loop. When a PydanticAI agent’s tool returns an empty list, an error model, or a “not found” response that validates successfully against the return type, the LLM has no way to distinguish that from a useful partial result — it generates a follow-up tool call, which produces another empty/error result, and the loop continues until the model’s internal context limit is reached or you notice the API bill. This page shows how to break that loop early and add a dollar cap to any PydanticAI agent without touching your tool definitions or agent configuration.

PydanticAI’s tool validation and the loop failure mode

PydanticAI validates tool inputs and outputs at the type level. If your tool returns list[Document] and the return value is [], that is a valid list[Document] — no validation error, no exception, no interruption. The LLM receives the empty list as a legitimate tool result and must decide what to do next.

In practice, well-prompted agents handle empty results gracefully. The failure mode occurs in three scenarios:

Wrapping PydanticAI tools with RunGuard

PydanticAI tools can be registered as plain functions or as methods decorated with @agent.tool. RunGuard’s guard() wraps the underlying function and returns a callable with the same signature — compatible with both registration styles.

from pydantic_ai import Agent, RunContext
from pydantic import BaseModel
from runguard import guard, BudgetTracker, LoopDetectedError, BudgetExceededError

class SearchDeps(BaseModel):
    document_store: any

agent = Agent(
    model="anthropic:claude-sonnet-4-6",
    deps_type=SearchDeps,
    result_type=str,
)

# Define the raw tool function first
def _search_docs(ctx: RunContext[SearchDeps], query: str) -> list[str]:
    return ctx.deps.document_store.search(query)

# Wrap with RunGuard before registration
# guard() preserves the function signature for PydanticAI's introspection
guarded_search = guard(
    _search_docs,
    loop_window=8,
    loop_threshold=2,
    budget=BudgetTracker(max_usd=1.00),
)

# Register the guarded function as the tool
agent.tool(guarded_search)

async def run_query(user_query: str) -> str:
    deps = SearchDeps(document_store=my_store)
    try:
        result = await agent.run(user_query, deps=deps)
        return result.data
    except LoopDetectedError as e:
        return f"Search loop detected ({e.tool_name} repeated {e.count}x). Please try a more specific query."
    except BudgetExceededError as e:
        return f"Query cost limit reached (${e.accumulated_usd:.2f}). Please refine your query."

Using the @agent.tool decorator style

If you prefer PydanticAI’s decorator style, apply RunGuard’s decorator before @agent.tool. Decorator order matters: RunGuard must be the innermost decorator so it sees the actual tool function, not the PydanticAI wrapper.

from runguard import loop_guard  # decorator form of guard()

agent = Agent(model="openai:gpt-4.1", deps_type=SearchDeps)

# Apply decorators bottom-up: loop_guard fires first, then agent.tool registers
@agent.tool
@loop_guard(loop_window=6, loop_threshold=2)
async def fetch_record(ctx: RunContext[SearchDeps], record_id: str) -> dict:
    # Returns {} if not found — valid type, but useless result
    return ctx.deps.document_store.get(record_id) or {}

@agent.tool
@loop_guard(loop_window=6, loop_threshold=2)
async def list_records(ctx: RunContext[SearchDeps], filter_key: str) -> list[dict]:
    return ctx.deps.document_store.list(filter_key)

Detecting cross-tool dependency loops

The query-rephrase loop is caught by fingerprinting a single tool. The cross-tool dependency loop (tool A returns an ID, tool B uses it and fails, tool A is called again) requires RunGuard’s session-level loop detector, which tracks the sequence of tool calls, not just individual tool repeat patterns.

from runguard import SessionGuard

# SessionGuard tracks cross-tool patterns within one agent.run() call
session_guard = SessionGuard(
    sequence_window=6,     # look at last 6 tool calls as a sequence
    sequence_threshold=2,  # trip if same 2-tool sequence repeats twice
)

# Inject into each tool via a shared context object
async def run_with_session_guard(query: str):
    async with session_guard.session() as s:
        deps = SearchDeps(document_store=my_store, run_guard=s)
        return await agent.run(query, deps=deps)

PydanticAI native vs RunGuard: loop prevention comparison

ScenarioPydanticAI nativeRunGuard
Single-tool empty-result loopNo detectionLoopDetectedError on 2nd identical fingerprint
Structured output validation retry loopmax_retries (count only)BudgetTracker fires before retries consume budget
Cross-tool dependency loop (A→B→A)No detectionSessionGuard detects repeated tool-call sequences
Per-run dollar capNoBudgetTracker raises BudgetExceededError
Type-safe tool inputs/outputsFull Pydantic validationFingerprint includes validated value, not raw LLM output
Async supportFull async/awaitguard() wraps both sync and async tools transparently

Running PydanticAI in streaming mode

PydanticAI supports streaming responses via agent.run_stream(). Loop detection works the same way in streaming mode: the guard triggers before the LLM call, not after, so the breaker fires before a new streaming response starts rather than interrupting a stream in progress. This is the safest behavior for streaming consumers — they never receive a partial response that cuts off mid-sentence due to a budget trip.

Add loop prevention to your PydanticAI agent today

RunGuard’s Python SDK installs with pip install runguard. Wrap your PydanticAI tool functions with guard() or the @loop_guard decorator, attach a BudgetTracker, and catch LoopDetectedError and BudgetExceededError in your run loop. Full compatibility with PydanticAI’s typed dependency injection and async execution model.

Get started with RunGuard — or see the same pattern for Phidata / Agno, Haystack, and Python agents generally.