These are the failures that sit between the agent loop and the platform running it: a tool bug burns through a month's budget in an hour, a database goes offline mid-run, the user closes their browser tab while the agent is still working, or your code accidentally hammers a provider faster than it can respond. None of these are "the LLM made a mistake" — they're "the system around the LLM broke." This chapter walks through each failure in plain language, then catalogs the call-counting, checkpointing, cancellation, and rate-control surfaces the five frameworks expose for these conditions.
Agent runs cost real money. A single user request can produce dozens of model calls and hundreds of tool invocations, and a bug in a tool description or a poorly-tuned supervisor can multiply that by 10× without anyone noticing. None of the five frameworks ship a token-cost meter — they don't track dollars. What they do expose are call-count ceilings: middleware and field defaults that bound how many model or tool invocations a thread, run, or agent can produce before the loop is forced to stop.
A developer ships a small change: a new tool that scrapes a URL and returns the page text. The tool description is slightly ambiguous, so the LLM keeps calling it again and again hoping for a different result. One user request triggers 200 tool calls before anyone notices — $40 of tokens for a single conversation. Multiply that across a few hundred users over a weekend and you've blown a month's budget. The agent loop itself had no way to know any of this was unusual; it just kept doing what the LLM asked.
Call-count caps are a blunt safeguard against this. They don't know the cost in dollars, but they know "this thread has issued 50 model calls — that's more than the limit." The granularity matters: a per-run cap protects against single-request runaways, a per-thread cap protects against a long-running conversation slowly piling up calls, and a per-tool cap protects against one buggy tool dominating the run. LangChain v1's middlewares are the only built-ins in this catalog that distinguish thread from run and offer per-tool scoping; the others ship a single integer cap.
| Framework | Built-in call-budget surface | Source |
|---|---|---|
| LangChain v1 | ModelCallLimitMiddleware with thread_limit + run_limit kwargs and exit_behavior: Literal["end", "error"]; counts persist across runs when keyed to a thread. |
libs/langchain_v1/langchain/agents/middleware/model_call_limit.py:89 |
| LangChain v1 | ToolCallLimitMiddleware with optional tool_name scoping plus thread_limit / run_limit; exit_behavior adds "continue" (block exceeded tools, let others proceed). |
libs/langchain_v1/langchain/agents/middleware/tool_call_limit.py:189 |
| LangChain (classic) | AgentExecutor.max_iterations (default 15) bounds reasoning steps; max_execution_time: float | None = None applies a wall-clock cap via asyncio_timeout(). |
libs/langchain/langchain_classic/agents/agent.py:1028 |
| LangGraph | No call-count or cost middleware. recursion_limit bounds super-steps, not LLM calls or tokens. |
— |
| CrewAI | BaseAgent.max_iter: int = Field(default=25, …) — the per-agent reasoning-step ceiling that doubles as a call-count proxy. |
agents/agent_builder/base_agent.py:251 |
| Google ADK | LoopAgent.max_iterations: Optional[int] = None — only applies to the explicit LoopAgent wrapper; unbounded by default. |
src/google/adk/agents/loop_agent.py:51 |
| Semantic Kernel | FunctionChoiceBehavior.Auto.maximum_auto_invoke_attempts — module-level default DEFAULT_MAX_AUTO_INVOKE_ATTEMPTS = 5, assigned to the field. |
connectors/ai/function_choice_behavior.py:18 |
Agent runs are long-lived — a multi-step task with several tool calls can take minutes. That's a much bigger window for the underlying infrastructure to fail than a typical web request. The pod gets OOM-killed, the database connection drops, a network partition isolates the worker. The question is whether a different process can pick up where the crashed one left off, or whether the run dies with the process. The answer depends entirely on whether the framework persists run state to durable storage as it goes.
Your agent is six steps into a ten-step research task — three searches done, two documents summarised, a draft underway. The pod hosting the run gets evicted by Kubernetes during a node upgrade. Without durable state, that work is gone: when the request retries (or the user retries), the agent starts from scratch, paying for all six steps again and asking the user to wait through the same minutes of work. With durable state, a different pod loads the last checkpoint, sees that steps 1–6 completed, and starts the run from step 7. The user might not even notice.
The mechanism that makes this work is a checkpointer: a storage backend the
framework writes to after each step. LangGraph is the only framework in this catalog
that ships one as a first-class abstraction (BaseCheckpointSaver) with
SQLite and Postgres implementations. The others let you record events for
observability, but they don't snapshot mid-run state in a form another process can
resume from.
| Framework | Durable-state surface | Source |
|---|---|---|
| LangGraph | BaseCheckpointSaver[V] — the abstract persistence interface every implementation extends. |
libs/checkpoint/langgraph/checkpoint/base/__init__.py:150 |
| LangGraph | SqliteSaver(BaseCheckpointSaver[str]) + AsyncSqliteSaver — local-file checkpoint store; same package ships a sync and async variant. |
libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py · aio.py |
| LangGraph | BasePostgresSaver(BaseCheckpointSaver[str]) — Postgres-backed durable state for cross-process resume. |
libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py |
| LangChain (classic + v1) | No checkpoint abstraction. RunnableWithMessageHistory persists chat history through a BaseChatMessageHistory implementation, but does not snapshot mid-run agent state. |
— |
| CrewAI | No mid-run checkpointing. Crew exposes Crew.kickoff() as a single call; persistence is per-task output via memory backends, not a resumable run. |
— |
| Google ADK | BaseSessionService stores session events; runs are not snapshotted to a checkpoint that another process can resume from. |
src/google/adk/sessions/base_session_service.py |
| Semantic Kernel | Process framework provides ProcessStateMetadata for serialising in-flight processes; the agent runtime does not persist actor state mid-run. |
python/semantic_kernel/processes/process_state.py |
The user closes their browser tab. Your API gateway's 60-second timeout fires. The upstream service that called your agent gives up. In every case, your agent should also give up — quickly, releasing tokens and external resources. But "stopping" a running agent is harder than it sounds: there might be a model call mid-flight, several tools running in parallel, sub-agents waiting on each other. The framework needs a way to say "stop" that reaches all of them.
A user clicks "cancel" on a long-running query. Your gateway sends a cancel signal to the agent service. Without a clean cancellation pathway, the agent keeps running until it finishes — the user is gone, but the agent is still calling the model, racking up tokens, hitting external tools that might cost money, and tying up worker capacity. With proper cancellation, the cancel signal propagates: the in-flight model call is aborted, every running tool call is told to stop, sub-agents shut down, the run exits within milliseconds.
Two API shapes exist in the catalog. Most frameworks rely on Python's built-in
cancellation: when the surrounding async task is cancelled, an
asyncio.CancelledError propagates through every awaiting coroutine. This
works as long as no one in the chain swallows the exception. Semantic Kernel takes a
different approach: it ships an explicit CancellationToken object you pass
through orchestration constructors. Calling cancel() on the token fires
registered callbacks and cancels every linked future — useful when an agent
orchestration spans actors that aren't all in a single asyncio task.
| Framework | Cancellation API | Source |
|---|---|---|
| Semantic Kernel | CancellationToken with cancel(), is_cancelled(), add_callback(), and link_future(); passing the same token through orchestration constructors propagates cancellation to every actor. |
agents/runtime/core/cancellation_token.py:10 |
| LangGraph | Pregel runner branches on isinstance(exception, asyncio.CancelledError) to distinguish cancel from other exceptions; cleanup uses contextlib.suppress(asyncio.CancelledError). |
libs/langgraph/langgraph/pregel/_runner.py:578 · main.py:3395 |
| LangChain (classic) | max_execution_time: float | None on AgentExecutor drives an asyncio_timeout() wrapper around the loop; awaiting tools propagate asyncio.CancelledError normally. |
libs/langchain/langchain_classic/agents/agent.py:1028 · :1644 |
| CrewAI | No first-class cancellation type. max_iter and max_rpm bound execution; hard cancel relies on host-level asyncio.wait_for wrapping Crew.kickoff_async(). |
— |
| Google ADK | Cancellation rides through async generators in BaseAgent.run_async; there is no dedicated token type. |
src/google/adk/agents/base_agent.py |
This is the inverse of Chapter 2's rate-limit problem: instead of recovering from a
provider's 429, you're trying to prevent the 429 by capping the rate
at which you send requests. Self-throttling at the agent layer is a polite-neighbor
mechanism that keeps a single agent from hammering the provider faster than it should,
typically configured per-agent or per-process.
Your CrewAI agent is processing a batch of 200 documents in parallel. With no
self-throttle, it issues 200 model calls in the first second, hits the provider's
per-minute token limit, and the next 150 calls return 429s. The framework retries them
with backoff — most succeed eventually, but the run takes 10× as long because of the
cascade of retries. With max_rpm=60 set on the agent, the
RPMController spaces calls so the per-minute counter never exceeds 60; no
429s, no retries, no thundering herd.
Self-throttling is in-process by definition — the counter lives in Python's
threading primitives. If you have ten agent processes all talking to the
same provider account, each will have its own counter, and the provider sees 600 RPM
despite each agent's max_rpm=60. Cross-process throttling lives at a
different layer — typically a gateway proxy with a distributed token bucket. Of the
five frameworks, only CrewAI ships an in-process limiter; the rest expect this to be
handled by the model client or platform layer.
| Framework | Self-throttle surface | Source |
|---|---|---|
| CrewAI | RPMController(BaseModel) with max_rpm: int | None = None, private _current_rpm counter, _timer, and a _lock; check_or_wait() blocks when the cap is reached and a 60-second timer resets the counter. |
src/crewai/utilities/rpm_controller.py:8 |
| CrewAI | BaseAgent.max_rpm: int | None = Field(default=None, …) wires per-agent caps into the RPMController; None means unlimited. |
agents/agent_builder/base_agent.py:240 |
| LangChain v1 | No per-second limiter. ModelCallLimitMiddleware caps thread/run totals, not rate. |
— |
| LangGraph | No first-class RPM control. Throttling is delegated to the underlying chat-model client. | — |
| Google ADK | No first-class RPM control. | — |
| Semantic Kernel | No first-class RPM control. | — |
Source-grounded observations on operational failure surfaces.
ModelCallLimitMiddleware and ToolCallLimitMiddleware both
accept thread_limit and run_limit as independent kwargs.
CrewAI max_iter=25, ADK LoopAgent.max_iterations=None, and SK
maximum_auto_invoke_attempts=5 are single-integer caps with no
conversation-vs-invocation distinction.
ToolCallLimitMiddleware takes an optional tool_name kwarg in
tool_call_limit.py:189, scoping the count to a single tool and adding
"continue" as an exit behavior so other tools are unaffected. No other
framework exposes per-tool counting.
BaseCheckpointSaver[V] in checkpoint/base/__init__.py:150
plus SqliteSaver and BasePostgresSaver implementations let a
different process resume an interrupted run. The other four frameworks have no
equivalent mid-run snapshot abstraction.
CancellationToken in agents/runtime/core/cancellation_token.py:10
exposes cancel(), add_callback(), and
link_future(); it propagates explicitly through orchestration constructors.
LangGraph and LangChain rely on asyncio.CancelledError propagation.
AgentExecutor.max_execution_time: float | None = None at
libs/langchain/langchain_classic/agents/agent.py:1028 drives an
asyncio_timeout() at line 1644. LangGraph, CrewAI, ADK, and SK have no
first-class wall-clock budget — timeouts are imposed by the host.
RPMController in utilities/rpm_controller.py:8 uses
threading.Timer + threading.Lock for a per-minute counter
wired to BaseAgent.max_rpm. The counter is in-process; cross-process RPM
is left to external infrastructure.