Circuit Breaker Open-Core
The circuit breaker is the decision engine that evaluates every AI agent tool call and returns an enforcement action — BYPASS, ENHANCE (from a control plane's evaluator), HIJACK, REASK, or KILL. It operates on the hot path between the proxy and the LLM provider, so every millisecond matters.
Design goals:
- Evaluation is in-process with no model call. Cost is measured per payload size in
packages/proxy/benches— there is no single figure, because it is dominated by request size. - Fail-closed by default for the proxy's connectivity to the control plane — if the model-request policy check can't complete, block the request (see Proxy-side fail mode). The hook gate fails closed too, check by check: when one of its checks (the SOP rules, SSO group policy, the MCP server registry, image provenance, and so on) errors, the call is refused with
GOVERNANCE_UNAVAILABLE, unless another check refuses it first with its own reason or the workspace is in shadow mode (see How a verdict is decided). - Graceful degradation — if a backend is unavailable, fall back to the next tier
- Zero single points of failure — Valkey cache and Postgres each provide a degradation layer
Hot path architecture
Two different paths evaluate a request, and it is worth keeping them apart.
Model requests go through the proxy. A standalone proxy (no control-plane URL, the open-core default) applies its own daily spend cap and makes no policy call. With a control plane, the proxy calls POST /api/v1/policy/check on it (routes/evaluate.ts) — the budget and loop-governance gates below.
Tool calls are decided by the gates, with their own order of checks: locally from the policy snapshot, and with a control plane also at its hook gate endpoint, where SSO-group clearance runs on the server (see §3 below). Neither is part of the model-request path. Loop detection is different again: it is an anomaly detector running in-process in the proxy, not a control-plane call.
The proxy-side model-request sequence, standalone:
Model request arrives at proxy (:4000)
│
▼
┌────────────────┐
│ Daily spend │ ◀── maxDailyBudgetUsd in ~/.intutic/config.json
│ cap (local) │ (default $10) less today's spend on this machine
└────────┬───────┘
├── over the cap ──▶ 429 OVERAGE_HARD_CAP_EXCEEDED
│ pass
▼
On to the proxy's own checks (DLP, rules, detectors); no policy pre-checkThe cap refuses a request whose estimated cost exceeds what is left of the day's budget; see Budgets. With no control plane the workspace hard-cap check answers clear, so this is the only spend cap a standalone proxy applies.
With a control plane, the sequence is:
Model request arrives at proxy (:4000)
│
▼
┌────────────────┐
│ 1. Budget Gate │ ◀── Valkey: v2:budget:hard_block:{wk_id}
│ (Valkey GET) │ + loop governance kill check
└────────┬───────┘
│ pass
▼
┌────────────────┐
│ 2. Loop budget │ ◀── loop_run status must be ACTIVE
│ governance │ (LoopGovernanceService)
└────────┬───────┘
│ pass
▼
┌────────────────┐
│ 3. Plan daily │ ◀── daily token and session limits
│ limits │ of the workspace's plan
└────────┬───────┘
│ pass
▼
Final verdictIf any gate returns a deny/kill, evaluation short-circuits immediately — subsequent gates are skipped.
1. Budget gate
The fastest check — a single Valkey key lookup.
How it works:
- The billing cron job sets
v2:budget:hard_block:{workspace_id}="1", expiring at midnight UTC, when a workspace's daily spend exceedsdaily_spend_cap_usdand the workspace has the hard cap on (enforcement_mode: "hard"andworkspace_hard_cap_enabled: truein its settings) - The policy check reads this key — if present → deny immediately
- Also checks loop-level budget caps: if a loop run (
loop_run_id) has any status other thanACTIVE→ deny
Cost: A single Valkey GET. No model call, no Postgres round trip.
// services/control-plane/src/routes/evaluate.ts
const budgetBlock = await valkey.get(budgetHardBlockKey(workspace_id))
if (budgetBlock) {
return c.json({ action: 'deny', reason: 'Workspace budget cap exceeded' })
}The proxy also does a local budget check before even calling the control plane — checking v2:budget:hard_block:{workspace_id} directly from its own Valkey connection and refusing with 429 OVERAGE_HARD_CAP_EXCEEDED while it is set. This means budget blocks take effect with zero network round-trips. When a control plane is configured but the key cannot be read, the proxy refuses with 503 BUDGET_UNVERIFIABLE.
→ Source: store/valkey.rs (hard_block, proxy-side); routes/evaluate.ts in the control plane, which is not open source
Plan daily limits
The policy check then compares the workspace's token count and session count for the UTC day with its plan's daily limits, and denies the request once either is exceeded. A request from a session already counted today is not refused by the session limit. If these counters cannot be read, the check allows the request.
2. Loop breaker
Detects when an agent is stuck calling the same tool over and over.
Algorithm: consecutive-run counting over the session's tool sequence
- Walk the tool sequence, counting the current run of identical consecutive calls
- A run of 5 or more raises a finding, with confidence scaled by how far past the threshold the run has gone
- An intervening different tool resets the run — a repeated tool that is making progress between calls does not trip it
// packages/proxy/src/plugins/anomaly/detectors.rs
const REPETITION_THRESHOLD: usize = 5;The verdict is REASK, not KILL. Five-in-a-row is a real signal, but the number five is a chosen threshold with no measured false-positive rate behind it, so it does not qualify to block under the promotion rule. A genuinely stuck agent is told it has repeated itself and can change approach; one doing repetitive but productive work says so and continues.
Graceful degradation: the detector reads the sequence already in the request context, so it has no cache dependency to degrade.
Proxy-side fail mode
The proxy has its own circuit breaker behavior, configured via PolicyConfig:
// packages/proxy/src/config.rs
pub struct PolicyConfig {
pub control_plane_url: Option<String>,
pub fail_closed: bool, // default: true
pub timeout_ms: u64, // default: 3,000ms
}| Setting | Behavior |
|---|---|
fail_closed: true (default) | If the policy check times out or fails → block the request |
fail_closed: false | If the policy check times out or fails → allow the request (fail-open) |
timeout_ms: 3000 | Maximum time to wait for the control plane policy check response |
A custom WASM or Rego rule that reaches no verdict (deadline, budget, an error, or a result that is not a verdict) is outside this setting: it blocks the request with GOVERNANCE_UNAVAILABLE either way, because an agent can cause it by padding its input. See When a rule reaches no verdict.
Fail-closed is the safe default
In production, always use fail_closed: true. Fail-open mode should only be used during initial setup or development when the control plane is not yet deployed.
Additional evaluation layers
Beyond the three hot-path gates, these layers also evaluate traffic. SSL enforcement only records. The proxy-side layers run inline: DLP and tool-poisoning redaction rewrite the request before it is forwarded, and DLP can refuse it outright (see Data flow between nodes); SnipCompactor rewrites a non-streamed response:
| Layer | What it does | Runs on |
|---|---|---|
| SSL enforcement | Scheduling, structural and logical checks against the session's SOP graph | Every gated tool call — in shadow: findings are recorded to detector_findings and the call proceeds |
| SSL compliance reporting | Reports which SSL graph steps a session followed | On demand, POST /api/v1/sessions/:id/ssl-audit |
| DLP Scanner | Regex-based secret detection, plus validated PII detectors for payment cards, IBANs and US Social Security numbers (email and phone opt-in) | Every request (proxy-side, pre-forwarding), and responses |
| Tool-poisoning redaction | Strips hidden/adversarial instructions from third-party MCP tool descriptions before the agent ever sees them ([REDACTED_TOOL_POISON]) — the same call still makes sense to the agent, it just loses the injected instruction | Every request, unconditionally (proxy-side, pre-forwarding) |
| SnipCompactor | Token compression of tool-result text — collapse repetitions, truncate JSON | Non-streamed responses (proxy-side, after the output DLP scan), so the saving lands in the next turn's prompt |
The DLP scanner, tool-poisoning redaction, and SnipCompactor run in the proxy (Rust, on the developer's machine) — they never hit the control plane. SSL enforcement runs in the control plane, at the hook gate.
Measured, not assumed: the detector behind tool-poisoning redaction was run against 10,753 real tool and parameter descriptions (14 BFCL v3 splits, 2,711 tool + 8,042 parameter descriptions) and produced zero false positives. It is deliberately narrow — 8 patterns tuned specifically for hidden instructions in tool descriptions (among them an order to run a destructive command: a recursive delete of the filesystem root or the home directory, formatting or raw-writing a disk, DROP DATABASE, a download piped into a shell), disjoint from the conversational jailbreak patterns the request-body scan looks for — so a legitimate tool with an unusual description doesn't get flagged for describing itself. Source: packages/proxy/src/tool_poison.rs, corpus at packages/proxy/tests/corpus/tooldesc/tooldesc.jsonl.
SSL enforcement records; it does not block
It ships shadowed on purpose. The promotion rule requires advisory telemetry from real traffic showing a false-positive rate in the 0.1–1% band before a control that can stop a tool call is allowed to. SSL enforcement had never executed at all until it was wired here, so it has no such measurement yet. Findings are visible through GET /api/v1/findings and can be adjudicated; the rate is reported by GET /api/v1/findings/stats, grouped so shadowed findings are counted separately.
Valkey key patterns
All circuit breaker state lives in Valkey for fast access:
| Key pattern | Purpose | TTL |
|---|---|---|
v2:budget:hard_block:{workspace_id} | Budget cap exceeded flag, set by the billing cron | Until midnight UTC |
v2:budget:{workspace_id}:monthly_limit | Monthly spend limit | Persistent |
v2:budget:{workspace_id}:daily_limit | Daily spend limit | Persistent |
v2:pcas:sso_group:{workspace_id} | Cached SSO group policy, read by the gate | 60 s |
intutic:loop:{loop_run_id} | Loop governance state | 7 days |
Continuous self-verification
The circuit breaker's own controls are checked, not just trusted:
- Guard-liveness probes re-run every guard against a violating and a benign context every 15 minutes, so a control that stopped firing is caught by the platform, not discovered in a postmortem. Results are queryable at
GET /intutic/probes. → Source: probes.rs - Silent-gate detection flags a gate that has gone quiet — the absence of expected activity, not a failed check — as its own finding, run hourly alongside the rest of the scheduled governance sweep. A gate that silently stops gating is an enforcement outage, and this is what catches it without waiting for someone to notice nothing was blocked.
Related
- Enforcement Actions — BYPASS/ENHANCE/HIJACK/REASK/KILL verdicts
- Harnesses — How the proxy and sync daemon connect
- Standard Operating Procedures — SOP definitions and policy evaluation rules
- Custom Filters (WASM) — Custom tool-call filtering and policy hooks