Skip to main content

Session circuit breaker

Stopping a runaway session​

A circuit breaker protects you from a single conversation that starts spending unusually fast. You attach it to a budget that counts only recent spend, such as the last five minutes. While that window is over budget, the breaker refuses any further model work in that session, even in the middle of a reply, and your frontend gets a status it can show. Nobody has to notice and nobody has to decide. It is a Compass Pro feature.

What you'll learn
  • How a circuit breaker differs from a session budget
  • How to attach one to a windowed budget with a cooldown
  • Where the breaker stops work: turn, step, and background
  • What your frontend receives when a turn is denied
Talk this page through with your coding agent

To stop further model work in such a case, you need a check at the very point where that work starts. A circuit breaker is exactly that: it looks at the session's recent spend and decides whether to allow the next piece of work.

Before we get to how it works, let's see how it relates to session budgets.

Budgets vs. the breaker​

A session budget is a named cost limit that Parlant checks per session. It comes in two flavors: a cumulative budget counts spend since its baseline, and a windowed budget counts only recent spend.

A cumulative budget is a business limit. When a session has spent enough over its life, you hand it to a person. It reacts between turns, and that is the right speed for a business decision.

A spending burst is a different problem. A session that spends in five minutes what it should spend in a day needs to be stopped now, mid-turn if necessary, without anyone deciding anything.

That is what the circuit breaker does. You attach it to a windowed budget, one that counts only spend inside a rolling period, such as the last five minutes. While that window is over budget, the breaker denies further chargeable work (i.e., any engine work that calls a priced model).

Note that the breaker is not an exact final-bill ceiling. It stops further work at its checks once measured spend exceeds the budget. Work already admitted can still incur cost, and compaction is exempt, as described below.

info

Spend management covers pricing and budgets. This page covers the breaker itself, which is a Compass Pro feature.

Setting one up​

Now that we understand what the breaker is for, let's set one up.

Budgets are created on the server's pricing, the object await server.set_pricing(...) returns once you have told Parlant what each model costs. That is the pricing in the example below.

Its price table is keyed by a bare model id, which prices that model under any provider, or by provider:model, which prices it for one provider only. Charges that OpenRouter or LiteLLM report themselves win over the table, so a session served entirely through them needs no table at all.

Every usage record counts toward the window, including the prompt-cache warm-up request Parlant makes when a session is created.

You create a budget with a window, then enable the breaker on it with a cooldown: the period the breaker stays open before it measures the window again.

from datetime import timedelta
from decimal import Decimal

import parlant.pro

runaway_budget = await pricing.create_session_budget(
name="runaway-protection",
value=Decimal("0.50"), # in the pricing's currency
window=timedelta(minutes=5), # only the last five minutes count
)

await parlant.pro.enable_circuit_breaker(
runaway_budget,
cooldown=timedelta(minutes=10), # how long a tripped session stays denied
)

Once enabled, the breaker checks the budget before each new piece of chargeable work. When the session's spend in the last five minutes exceeds the value, the breaker opens and stays open for the cooldown.

When the cooldown expires, Parlant measures the window again. A session that has calmed down continues; a session whose recent cost is still above the budget begins another cooldown.

Where it gates​

Once the breaker is enabled, where exactly does it step in? Stopping between turns is not enough for a burst, because a single turn can run many model calls.

Recall that Parlant produces a reply in a loop of steps, each one a model call that may run tools or draft the message. It also does optional background work after the reply is sent. The breaker checks the same windowed budget at all three boundaries:

BoundaryBehavior when open
TurnAcknowledge the customer message, emit a terminal ready status with the cost-control details, and perform no model work.
StepStop before the next step of the response loop. Text already streamed is never truncated.
BackgroundSkip optional post-response work. Compaction stays exempt, because it reduces future cost.

Note that the step check includes priced usage from the current, unfinished turn. This means a multi-step runaway turn is stopped before its usage is even persisted.

What the customer sees​

When the breaker denies a turn, your customer does not receive an agent message. Instead, the turn ends with a status event, one of the events Parlant emits alongside messages to report a turn's progress, carrying details your frontend can act on:

{
"status": "ready",
"data": {
"cost_control": {
"circuit_breaker": "open",
"scope": "session",
"retry_after_utc": "2026-07-16T12:34:56+00:00",
"reason": "Session ... exceeded budget 'runaway-protection' ..."
}
}
}

What you do with it is up to you. Your coding agent can show a retry timer based on retry_after_utc, a temporary "unavailable" state, or whatever fits your product.

Note that retrying before that time is harmless. It returns another denial.

Combining it with handoff​

Now that we have covered the breaker on its own, let's look at how it works alongside handoff.

Handoff means switching the session to manual mode, where the agent stops replying on its own so that a person can take over. In Parlant, you do this with a budget observation: an observation (a rule that names a situation) whose evaluation function is the cumulative budget's exceeded check, and whose on_matched handler switches the mode.

tip

Spend management builds that handoff handler step by step.

The two controls are meant to run together. Both share the server pricing and read the same priced ledger, while using independent budgets:

handoff_budget = await pricing.create_session_budget(
name="handoff",
value=Decimal("10.00"), # no window: counts the session's whole life
)

runaway_budget = await pricing.create_session_budget(
name="runaway",
value=Decimal("0.75"),
window=timedelta(minutes=5),
)

await parlant.pro.enable_circuit_breaker(
runaway_budget,
cooldown=timedelta(minutes=10),
)

Note that creating handoff_budget only defines the limit. You still need to attach its exceeded evaluator to an observation with the handoff handler shown in Spend management. Otherwise, the handoff in this example never runs.

How the two are ordered​

To make sure the two controls do not fight, Parlant fixes their order within a turn.

Budget observations are evaluated first, before any model work, and their handlers run. If a handler switched the session to manual, the turn stops cleanly right there.

Otherwise, the turn reaches the breaker's gate, which may still deny it. For example, a session that has crossed both limits is handed off rather than merely paused.

Custom admission logic​

If the built-in breaker does not fit your needs, your coding agent can bring your own admission logic instead.

The breaker is built on CostControlPolicy, the engine's admission port. A port is an abstract type the engine depends on, bound in the container to whatever implementation you choose. This one decides at each boundary whether work may proceed.

tip

Overriding engine behavior covers ports and the container in more detail.

enable_circuit_breaker adds a budget-backed policy to Parlant's built-in composite policy. If you bind your own CostControlPolicy in the container, it replaces that composite entirely, and enable_circuit_breaker then raises, rather than registering a breaker the engine would never consult.

Note that ordinary policy exceptions fail open, so an unavailable policy never makes your agent unavailable. The one exception is PricingConfigurationError, which stays visible, because silently ignoring an unpriced model would under-count a configured budget. It names the full provider:model key of the unpriced model.