Safety Primitives
Autonomous agents with LLM access can incur unbounded cost when a vendor API flakes, a retry policy misfires, or an output guardrail silently rejects every attempt. AgentOS ships small, independent primitives that bound the failure modes behind runaway spend, stuck loops and hung tools. Three run inside the runtime: generateText() and streamText() consult the provider health registry, processRequest() reserves against a configured spend meter, and generateText(), streamText(), generateObject(), embedText() and agent() check each provider call against a CostGuard when they are given a spend budget (Cost Optimization). A host calls the others around its own model and tool calls, and can call a CostGuard of its own the same way.
Each has defaults and works alone or composed with the others (How they work together).
These are operational guards — they don't read message content. For content-level safety (toxicity, PII, prompt injection, folder-level filesystem permissions) see Guardrails.
A host's guard chain
Each layer is independent; use any subset.
CircuitBreaker
Three-state (closed -> open -> half-open) pattern wrapping any async operation. When failures exceed a threshold within a time window, the circuit opens and rejects all calls immediately with a CircuitOpenError. After a cooldown period, it transitions to half-open and allows probe calls through. If probes succeed, it closes again.
Config
| Option | Default | Description |
|---|---|---|
name | required | Breaker identifier (used in errors and callbacks) |
failureThreshold | 5 | Failures before opening |
failureWindowMs | 60,000 | Window in ms for counting failures |
cooldownMs | 30,000 | Time in open state before probing |
halfOpenSuccessThreshold | 2 | Successes needed in half-open to close |
onStateChange | undefined | Callback: (from, to, name) => void |
Usage
import { CircuitBreaker, CircuitOpenError } from '@framers/agentos';
const breaker = new CircuitBreaker({
name: 'openai-api',
failureThreshold: 3,
cooldownMs: 60_000,
onStateChange: (from, to, name) => {
console.log(`[${name}] ${from} -> ${to}`);
},
});
try {
const response = await breaker.execute(async () => {
return await openai.chat.completions.create({ model: 'gpt-4o-mini', messages });
});
} catch (err) {
if (err instanceof CircuitOpenError) {
console.log(`Circuit open. Retry after ${err.cooldownRemainingMs}ms`);
}
}
// Inspect state
const stats = breaker.getStats();
// { name: 'openai-api', state: 'closed', failureCount: 0, totalTripped: 0, ... }
LLMProviderHealthRegistry
A status-aware, process-lifetime memory of LLM provider health, keyed by providerId. Wired into generateText and streamText so the next caller doesn't pay a full TLS round-trip to rediscover a provider that just returned 402 Insufficient Credits or 401 Invalid API key.
The plain CircuitBreaker above uses a single failure-threshold + cooldown pair per instance — fine for one-off operations. The router needs per-error-class behavior: open immediately on a payment or auth failure, but require a streak before tripping on a 429 or 5xx. This is what the registry adds.
| Error class | Threshold | Cooldown |
|---|---|---|
| 402 insufficient credits | 1 fail | 5 min |
| 401, 403 auth/forbidden | 1 fail | 30 min |
| 429 rate limit | 3 fails | 30 s |
| 5xx + unclassifiable | 5 fails | 60 s |
The 5-minute window on 402 reflects operational reality: credits might get topped up while a batch job is in flight. 30 minutes on 401/403 is longer because those failures usually require an env change plus redeploy. 429 cooldowns are intentionally short because rate limits typically lift in a single billing interval.
How the router uses it
- Before the primary call,
generateTextconsultsglobalLLMProviderHealth.isOpen(resolvedProviderId). If the breaker is open, it throws a syntheticLLMProviderCircuitOpenErrorwithhttpStatus: 503. The existingisRetryableErrorcheck recognizes that status and routes the call into the fallback chain. No network round-trip, no TLS handshake, no waste. - On a real provider error (anything caught in the outer try/catch except the breaker's own
LLMProviderCircuitOpenErrorand a call the caller stopped: a spend budget's refusal, a hook's error underhookErrors: 'throw', or the call'sabortSignalaborting),recordFailure(providerId, error)classifies the error by HTTP status and either trips immediately (for 401/402/403) or increments the streak counter (for 429/5xx). - On success,
recordSuccess(providerId)resets the streak counter so a future transient failure starts fresh. A single success does NOT shorten an already-open cooldown: the breaker is open precisely because we want to stop probing for a window. - In the fallback chain loop, every fallback entry is checked against
isOpen()before its attempt. A dead chain entry is skipped instantly, so the loop walks to the first healthy provider with O(N) constant-time checks rather than O(N) network calls.
Error classification
The registry reads HTTP status from three sources, in order:
[NNN] ...prefix inerror.message— the shapeOpenRouterProviderdecorates its errors with so downstream regex-based routing can find them.error.statusCodenumeric property —OpenRouterProviderErrorsets this explicitly.error.statusnumeric property — the Anthropic and OpenAI SDK shape.
If none of those resolves, the error is treated as the conservative transient class (5-failure threshold, 60 s cooldown). Better to under-protect on a one-off network blip than lock out a healthy provider.
Errors that judge the request rather than the provider do not count: a 4xx other than 401, 402, 403, 408 and 429 (a malformed body, an unknown model), a content-policy decline (code or type of content_filter, content_policy_violation or safety_violations), and a request larger than the model's context window.
Config
The policy table is fixed in code; the registry takes no options.
Usage
import { globalLLMProviderHealth, LLMProviderHealthRegistry } from '@framers/agentos/core/safety';
// Read state for an admin / diagnostics endpoint
const stats = globalLLMProviderHealth.getStats('openrouter');
if (stats?.state === 'open') {
console.log(
`OpenRouter circuit open; ${stats.cooldownRemainingMs}ms until close. ` +
`Last status: ${stats.lastStatusCode}, total trips: ${stats.totalTrips}`,
);
}
// Manually reset after a credit top-up so the next call probes immediately
globalLLMProviderHealth.reset('openrouter');
// Construct a private registry for a test
const isolated = new LLMProviderHealthRegistry();
isolated.recordFailure('mock-provider', new Error('[402] Test'));
expect(isolated.isOpen('mock-provider')).toBe(true);
Why a singleton
Provider health is process-wide state. Two concurrent generateText calls inside the same Node process see the same OpenRouter: if one just discovered it's at 402, the other shouldn't redo the discovery. The globalLLMProviderHealth singleton is the natural granularity. Tests construct their own LLMProviderHealthRegistry instances to keep state isolated across cases.
The registry is ephemeral by design: it lives in memory and resets on server restart. Persistent provider-health tracking would add complexity (Redis, write-through cache invalidation) for a problem the in-process singleton already solves for the dominant case: a long-running batch job hammering a degraded provider.
ActionDeduplicator
Hash-based recent action tracking with a configurable time window and LRU eviction. The caller computes the key string -- this class is intentionally generic. Use it to prevent duplicate votes, duplicate posts, or any repeated action within a window.
Config
| Option | Default | Description |
|---|---|---|
windowMs | 3,600,000 (1 hr) | Time window for dedup tracking |
maxEntries | 10,000 | Maximum tracked entries before LRU eviction |
Usage
import { ActionDeduplicator } from '@framers/agentos';
const dedup = new ActionDeduplicator({ windowMs: 900_000 }); // 15-minute window
const key = `vote:${agentId}:${postId}`;
if (dedup.isDuplicate(key)) {
console.log('Already voted on this post recently');
return;
}
dedup.record(key);
await castVote(agentId, postId);
// Or use the combined check-and-record method:
const { isDuplicate, entry } = dedup.checkAndRecord(`like:${agentId}:${postId}`);
if (isDuplicate) {
console.log(`Seen ${entry.count} times since ${new Date(entry.firstSeenAt)}`);
}
StuckDetector
Detects agents producing identical outputs or errors repeatedly. Uses fast djb2 hashing (no crypto overhead) to track output history per agent within a sliding window.
Detects three patterns:
repeated_output-- The same output appears N times in a rowrepeated_error-- The same error message appears N times in a rowoscillating-- Agent alternates between two outputs (A, B, A, B pattern)
Config
| Option | Default | Description |
|---|---|---|
repetitionThreshold | 3 | Identical outputs before flagging stuck |
errorRepetitionThreshold | 3 | Identical errors before flagging stuck |
windowMs | 300,000 (5 min) | Sliding window for history |
maxHistoryPerAgent | 50 | Max entries tracked per agent |
Usage
import { StuckDetector } from '@framers/agentos';
const detector = new StuckDetector({ repetitionThreshold: 3 });
// After each LLM call, check for stuck behavior
const check = detector.recordOutput('agent-1', response.content);
if (check.isStuck) {
console.log(`Agent stuck: ${check.reason}`);
// check.reason is 'repeated_output' | 'repeated_error' | 'oscillating'
// check.details has a human-readable description
// check.repetitionCount tells you how many repeats were detected
pauseAgent('agent-1');
}
// Also track errors
try {
await callLLM();
} catch (err) {
const errCheck = detector.recordError('agent-1', err.message);
if (errCheck.isStuck) {
// Same error 3 times in a row -- stop retrying
break;
}
}
// Clean up when an agent is removed
detector.clearAgent('agent-1');
CostGuard
Per-agent spending caps with three levels: session, daily, and single operation, kept in process memory. CostGuard stops nothing itself: the host asks canAfford() before a call and records the cost after it, and onCapReached fires when a recorded cost reaches a cap. CostGuard never throws CostCapExceededError, and AgentOS runs no CostGuard of its own unless a call carries a spend budget: a budget on generateText(), streamText(), generateObject(), embedText() or agent() keeps a CostGuard (the one passed as guard, or one it makes), checks each provider call against it before the call and records the call's cost after it, and refuses a call that would pass the budget with CostCapExceededError unless the budget is set to warn. reserveSpend() and reserveOperationSpend() throw the same error, with the cap type daily, when an admission would take a day's committed spending past its cap; that day's total lives in the caller's own tables, not in a CostGuard.
Config
| Option | Default | Description |
|---|---|---|
maxSessionCostUsd | $1.00 | Maximum spend per agent session |
maxDailyCostUsd | $5.00 | Maximum spend per agent per day |
maxSingleOperationCostUsd | $0.50 | Maximum spend for a single operation |
onCapReached | undefined | Callback: (agentId, capType, currentCost, limit) => void |
Usage
import { CostGuard } from '@framers/agentos';
const guard = new CostGuard({
maxDailyCostUsd: 2.00,
onCapReached: (agentId, capType, cost, limit) => {
console.log(`${agentId} hit ${capType} cap: $${cost.toFixed(4)} / $${limit.toFixed(2)}`);
pauseAgent(agentId); // the host's own pause
},
});
// Before each operation, check affordability
const check = guard.canAfford('agent-1', 0.003); // estimated cost
// A refusal names its cap in check.capType, with the total so far and the cap in check.currentCostUsd and check.limitUsd
if (!check.allowed) {
throw new Error(check.reason); // "Daily cost $5.0031 would exceed limit $5.00"
}
// After the operation, record actual cost
guard.recordCost('agent-1', actualCostUsd, 'llm-call-123');
// Per-agent overrides
guard.setAgentLimits('expensive-agent', { maxDailyCostUsd: 10.00 });
// Inspect spending
const snapshot = guard.getSnapshot('agent-1');
// { sessionCostUsd: 0.42, dailyCostUsd: 1.87, isSessionCapReached: false, ... }
// Daily totals reset at the process's local midnight. Manual reset:
guard.resetSession('agent-1');
guard.resetDailyAll();
Spend meter
A persisted allowance per account and period, for a product that sells a number of turns a month, or per account over a rolling window, for one that allows a number of calls in any hour. CostGuard caps one process's spending in memory; the spend meter keeps the count in a database several processes share, so two servers cannot both sell the last turn, and a restart loses nothing.
processRequest reserves before anything reaches a provider and the turn settles the reservation when it ends:
| What happens | The request | The reservation |
|---|---|---|
No operationId on the input | One error chunk, SYS_VALIDATION_ERROR | none |
| The period has no room | One error chunk, BILLING_ALLOWANCE_EXHAUSTED (402), with reason, period and remaining in details | none |
The same operationId again, still running or already counted | One error chunk, SYS_ALREADY_EXISTS | unchanged |
| The meter's store does not answer within its retry policy | One error chunk, SYS_SPEND_METER_UNAVAILABLE (503) | none |
| The turn finishes, or stops for a tool the host runs | The reply, or the request for the tool | consumed, with the turn's usage |
| The turn fails before any text reached the stream, a turn that fails while it saves its pause for a tool included | The error | released |
| The turn fails after some text reached the stream | The error | consumed |
| An output guardrail blocks the reply | The guardrail's error chunk | refunded (released, outcome replaced) |
| The process dies mid-turn | nothing more | settled by reconcile() once its lease runs out |
Each refusal reaches the caller before any model call. The turn settles in the orchestrator, so a caller that stops reading early still has its turn counted.
Config
SqlSpendMeter runs on a @framers/sql-storage-adapter store. Give it a Postgres adapter of its own (or the product's pool), never AgentOS's provenance-wrapped storage adapter.
| Option | Default | Description |
|---|---|---|
db | required | The store. It must offer transactions, persistence and concurrent access unless requireShared: false |
allowanceFor(accountId, period) | required | The account's allowance in units for the period, read at every reservation |
periodOf(now, accountId) | required unless windowMs is set | The period a moment falls in for the account, such as its calendar month in its own time zone. Not read with a window |
windowMs | none | A rolling window in milliseconds, in place of periods: the allowance holds the units consumed in the windowMs before each reservation and every unit still reserved, whatever its age |
leaseMs | 45000 | How long a reservation lives without a heartbeat; the turn heartbeats on each tool iteration |
maxAttemptsPerOperation | 3 | Reservations one operation may take, its retries after a release included |
retry | 3 tries, 50 to 400 ms, 2 s deadline | Retries of transient store errors before SpendMeterUnavailableError |
resolveUnknown(reservation) | none | Asked by reconcile() what became of an expired reservation: consumed, released or unknown |
resolveExpired(reservation) | none | Asked by reconcile() for each expired reservation in place of resolveUnknown and unknownAfterLease: consumed, released, or deferred until retryAt with its evidence, which keeps the reservation reserved and counted. See Deferred expiry |
unknownAfterLease | 'release' | How an expired reservation settles when the answer is unknown |
ensureSchema | true | Create the two tables when missing, and on tables that exist the indexes they lack; a product that runs its own migrations copies SPEND_METER_DDL, or spendMeterDdl(prefix) for its tablePrefix, and passes false |
tablePrefix | 'agentos_spend' | The start of the two tables' names, <prefix>_meter and <prefix>_reservations; their indexes are named idx_<prefix>_reservations_*, and idx_spend_reservations_* under the default. A lower-case letter and at most 37 more lower-case letters, digits or underscores, and not spend: the constructor throws on any other value, before any statement is built. At 38 characters the longest names, idx_<prefix>_reservations_account and idx_<prefix>_reservations_settled, take the 63 bytes Postgres keeps of an identifier |
onWarning(warning) | none | Told of each statement that failed while ensureSchema brought tables that exist up to the meter's indexes, with the statement and the error: an index the store refused, or the catalogue read before them. The meter uses the tables as they are |
With windowMs the meter counts from the reservations themselves, inside each reservation's own transaction and after it has taken the account row's lock, so two reservations cannot both take the last unit of a window. Every row then carries the period window, which is the period allowanceFor is asked for. A consumed unit counts within its accounting window: from its reservation until more than windowMs have passed. A still-reserved hold counts until explicit settlement, even across window boundaries; expiration alone does not release it. A released reservation never counts, and one whose lease ran out still counts until reconcile() releases it. snapshot() answers the units consumed and reserved inside the window and what remains. A meter given neither periodOf nor windowMs throws when it is constructed.
The window's count reads the index idx_spend_reservations_account, and purge() finds the reservations settled before its before through idx_spend_reservations_settled, on settled_at and state (idx_<prefix>_reservations_account and idx_<prefix>_reservations_settled under another prefix). The DDL creates both with the tables. On tables that exist, ensureSchema reads the store's catalogue before the meter's first statement (pg_indexes on Postgres, sqlite_master on SQLite) and runs the CREATE INDEX IF NOT EXISTS statement of each of the meter's indexes the tables lack, so a store whose tables an earlier release made gains them. A meter whose tables hold all three runs no index statement, so on Postgres its start takes no SHARE lock on the reservations table, the lock a CREATE INDEX takes before it checks IF NOT EXISTS. The indexes change no answer of the meter, so tables that refuse one, as Postgres does for a role that does not own them, are used as they are, and the statement and its error go to onWarning. Postgres holds back writes to a table while it builds an index on it, so a product with a large reservations table creates the two indexes first in its own migration, with CREATE INDEX CONCURRENTLY IF NOT EXISTS and the names and columns spendMeterDdl(prefix) gives them, and runs the meter with ensureSchema: false.
Several meters in one database
Meters with different rules take a prefix each. Every statement a meter runs names its own two tables, so its count reads only its own rows and its purge() deletes only its own rows. Meters under one prefix share its two tables and every row in them, and a purge by one reaches the settled reservations of the others.
import { SqlSpendMeter, spendMeterDdl } from '@framers/agentos';
// Twenty calls an account in any hour, in the default tables
const hourly = new SqlSpendMeter({ db, windowMs: 3_600_000, allowanceFor: () => 20 });
// Five hundred a month, in tables of its own: monthly_spend_meter and monthly_spend_reservations
const monthly = new SqlSpendMeter({
db,
tablePrefix: 'monthly_spend',
periodOf: (now) => new Date(now).toISOString().slice(0, 7),
allowanceFor: () => 500,
});
// The same tables and indexes for a product's own migration, with ensureSchema: false on the meter
const migration = spendMeterDdl('monthly_spend');
With no prefix, spendMeterDdl() answers SPEND_METER_DDL word for word; given a prefix the constructor refuses, it throws.
Usage
import { AgentOS, SqlSpendMeter } from '@framers/agentos';
import { createPostgresAdapter } from '@framers/sql-storage-adapter';
const db = createPostgresAdapter({ connectionString: process.env.DATABASE_URL!, max: 3 });
await db.open();
const meter = new SqlSpendMeter({
db,
allowanceFor: async (accountId) => (await plans.planOf(accountId)).turnsPerMonth,
periodOf: async (now, accountId) => monthIn(await accounts.timeZoneOf(accountId), now), // "2026-10"
resolveUnknown: async ({ operationId }) => ((await replies.storedFor(operationId)) ? 'consumed' : 'unknown'),
});
meter.startReconciler(); // every 15 seconds
const agentos = await AgentOS.create({ spendMeter: { meter, accountIdOf: (input) => input.userId } });
for await (const chunk of agentos.processRequest({
userId, sessionId, textInput,
operationId: messageId, // the product's durable id of this message: a retry with it is never charged twice
})) {
// ...
}
// The month so far, for the product's own meter line
const { used, remaining } = await meter.snapshot(userId);
// A plan change mid-period: the units used carry over, the new allowance applies at once
await meter.setAllowance(userId, 600);
Deferred expiry
A call whose work may outlive its lease (a worker still running, an inference not yet stopped) must not be released or consumed by a sweeper that cannot prove it stopped. Reserve it with requireFence: true and give the meter resolveExpired:
const meter = new SqlSpendMeter({
db, periodOf, allowanceFor,
resolveExpired: async (reservation) => {
const fence = await controlPlane.fenceOf(reservation.operationId); // the host's own evidence
if (!fence.transportClosed || !fence.inferenceTerminated) return { outcome: 'deferred', retryAt: Date.now() + 15_000, evidence: fence };
return { outcome: fence.served ? 'consumed' : 'released' };
},
});
await meter.reserve({ accountId, operationId: callId, requireFence: true });
A deferred reservation stays reserved and counted, with its evidence and its next check kept on its row; a resolver that throws defers it for one lease. A meter without resolveExpired (a legacy sweeper over the same tables) leaves a requireFence reservation reserved and counts it pending; an explicit settle() still ends it. A rolling window counts a reservation still reserved whatever its age. releaseExpiredSpend(store, at, { resolve }) does the same for a day's bound, with SpendDayStore.deferReservation keeping the next check.
A reconcile() pass writes each decision to the reservation as it read it, in one conditional statement: the same attempt, still reserved, still past its lease and, for a settlement on a meter without resolveExpired, still unfenced. A reservation that an opted-in pass defers, or that a heartbeat renews, while a legacy pass asks resolveUnknown about it stays reserved. While resolveExpired is asked, a heartbeat's renewed lease stands, and so does another pass's deferral to a next check after the pass's now, with its evidence; an operation released and retried meanwhile keeps the retry reserved under its own lease, with none of the earlier attempt's fence, evidence or next check. A meter without resolveExpired reads the ordinary expired reservations into its page first and the fenced holds only into the room they leave, each in expiry order on the (state, expires_at) index, so the fenced holds it leaves never keep one from settling, whatever the limit, and no pass sorts every due row to find its page.
Retention
A settled reservation's row stays, so that a retry of its operation is recognised, and a window meter keeps one row per account. purge({ before, limit }) deletes the reservations settled before before and, for a window meter, the account rows that hold nothing reserved and were last written before before. One call deletes at most limit of each (default 1,000) and answers how many of each it deleted, so a sweep calls it again while a count comes back full.
// Twenty calls an account in any hour
const hourly = new SqlSpendMeter({ db, windowMs: 3_600_000, allowanceFor: () => 20 });
// The sweep: what settled more than an hour ago is past the window, and goes
const { reservations, periods } = await hourly.purge({ before: Date.now() - 3_600_000 });
A reservation still reserved is never deleted, and neither is a period meter's period row, which holds the period's count: a month's snapshot() reads the same after its settled reservations are purged. For a window meter, a before at least one window back keeps every unit the window counts. Once an operation's row is purged the meter no longer knows the operation: a retry of it reserves and counts again, and a refund of it finds nothing, so before lies past the time either can come. purge() deletes across every account in its meter's two tables, so meters under one prefix share their retention, and a meter under a prefix of its own keeps its own.
Compound credit
A host that sells metered calls reserves each call's most before any work starts, and settles it once from measured quantities. @framers/agentos/safety/runtime carries the arithmetic and the ledger; the host supplies rates, caps and its allocation policy.
import {
InMemoryCompoundCreditStore,
grantCreditLot,
reserveCompoundCredit,
settleCompoundCredit,
ModelAttemptBudget,
} from '@framers/agentos/safety/runtime';
const store = new InMemoryCompoundCreditStore(); // SqlCompoundCreditStore on Postgres in a deployment
await grantCreditLot(store, { scopeId: 'scope-a', kind: 'paid', fundingRef: 'payment-1', principalMicro: 10_000_000, at: Date.now() });
const held = await reserveCompoundCredit(store, {
scopeId: 'scope-a', operationId: 'call-1', kind: 'call', requestHash: 'h', rates: { primary: rates },
mediaComponent: 'media_second', durationSeconds: 600,
attempts: { maxAttempts: 64, perAttempt: { input_token: 8_192, output_token: 512 } },
supplierDailyCapMicro: 10_000_000, at: Date.now(), expiresAt: Date.now() + 3_660_000,
});
if (held.status === 'reserved') {
const budget = new ModelAttemptBudget({ store, holdId: held.hold.holdId });
const attempt = await budget.authorize({ purpose: 'initial' }); // before every provider request
await budget.record(attempt.attemptId, { input_token: 900, output_token: 120 }, true);
await settleCompoundCredit(store, {
holdId: held.hold.holdId, connectedMs: 61_500, at: Date.now(),
fence: { mediaClosed: true, inferenceTerminated: true, evidence: 'room closed; worker exited', observedAt: Date.now() },
});
}
- A rate is rational (
micromicro-dollars for everyperunits), and a component's charge is one ceiling of the exact product, on BigInt.quoteCompoundLiabilityprices the full duration and every permitted attempt at the dearest authorized snapshot; reasoning tokens are counted once when the snapshot says output includes them. - A hold is allocated to lots in the policy's order (
promotionsFirstPolicy: earliest-expiring promotions, then paid lots); settlement consumes those shares in that order. A policy orders only the eligible lots (isLotEligible: active, unexpired, with a free balance), and the balance counts only those, whatever the policy. An expired promotion keeps the share an admitted hold took, and whatever that share does not consume retires. - The supplier side of a hold is checked against the platform's daily cap together with every hold still open, whatever day it was made on. Reservations take that check one at a time on one platform-wide lock (the supplier day of the epoch,
1970-01-01), so two on either side of midnight cannot both pass the cap. - Settlement needs evidence that the media closed and the inference stopped; without it the hold is deferred, still held.
reconcileCompoundCreditdefers when its resolver cannot tell or fails. - Every provider attempt (initial, tool continuation, retry, fallback) is authorized with
authorizeModelAttemptbefore it is sent; an attempt that never reports usage keeps its supplier most open. - Refunds take only a paid lot's free principal, reserved before the payment rail is asked (
refundCreditLot, thencompleteLotRefund). A refund id answersexistingonly for its own scope, lot and amount; another request under it, one racing it from another scope included, is refused asconflictand reserves nothing. A dispute freezes the lot and the account's admission; a lost reversal past the free balance is a receivable outside spendable credit. marginReportreconciles a scope's period: paid-funded revenue, promotional usage, reversals, refunds, supplier cost including failed work, compute by measured runtime, and other costs quantified or excluded; a missing cost makes itunreconciled, with no contribution stated. Every hold settled in the period needs a measured runtime, an explicit zero included, or compute stays unreconciled. The period's settlements are the ones its settlement entries name, however many holds the scope settled before.
With credit funding, the scoped voice factory uses ModelAttemptBudget below provider request and retry loops; a call with local funding counts its attempts with CountingAttemptBudget, which holds no money. The OpenAI provider's request loop counts every call's attempts with CountingAttemptBudget as well, at the call's maxAttempts or the provider's configured count, so an attempt past the count is refused before it is sent (Attempts). Call minutes remain quantity-only in CallMinuteMeter; rational-rate arithmetic and final component rounding belong to the ledger. Stop evidence, final supplier quantities and the hold's rate snapshots and lot allocations determine settlement. Do not release a deferred hold because its process stopped reporting. marginReport aggregates the ledger's recorded revenue and supplier cost with the measured runtime the host supplies; it neither invents a tariff nor makes unknown supplier cost zero.
ToolExecutionGuard
Wraps tool execution with a timeout and per-tool circuit breaker, so a hung tool returns a timeout result and a tool that keeps failing is refused while its breaker is open. Each tool gets its own circuit breaker instance and health tracking. execute() never throws: a timeout, an error or an open breaker comes back as { success: false }. A timed-out call is not cancelled; its promise runs on and its result is dropped.
Config
| Option | Default | Description |
|---|---|---|
defaultTimeoutMs | 30,000 | Default timeout per tool execution |
toolTimeouts | undefined | Per-tool timeout overrides (Record<string, number>) |
enableCircuitBreaker | true | Whether each tool gets its own circuit breaker |
circuitBreakerConfig | undefined | Config applied to per-tool circuit breakers |
Usage
import { ToolExecutionGuard } from '@framers/agentos';
const guard = new ToolExecutionGuard({
defaultTimeoutMs: 15_000,
toolTimeouts: {
'web-search': 45_000, // Search gets more time
'calculator': 5_000, // Calculator should be fast
},
});
const result = await guard.execute('web-search', async () => {
return await searchTool.run(query);
});
if (result.success) {
console.log(result.result); // The tool's return value
console.log(result.durationMs); // How long it took
} else {
console.log(result.error); // Error message
console.log(result.timedOut); // true if it was a timeout
}
// Health monitoring
const health = guard.getToolHealth('web-search');
// { totalCalls: 47, failures: 2, timeouts: 1, avgDurationMs: 3200, circuitState: 'closed' }
// All tools at once
const allHealth = guard.getAllToolHealth();
How they work together
A host composes the primitives around its own model call:
import { CostGuard, CircuitBreaker, StuckDetector } from '@framers/agentos';
const costGuard = new CostGuard({ maxDailyCostUsd: 2 });
const breaker = new CircuitBreaker({ name: 'llm' });
const stuck = new StuckDetector();
async function guardedCall(agentId: string, prompt: string) {
const affordable = costGuard.canAfford(agentId, 0.001); // the host's estimate
if (!affordable.allowed) throw new Error(affordable.reason);
const response = await breaker.execute(() => callModel(prompt)); // the host's model call
costGuard.recordCost(agentId, priceOf(response.usage)); // the host's pricing
const check = stuck.recordOutput(agentId, response.text);
if (check.isStuck) pauseAgent(agentId, check.details); // the host's own pause
return response;
}
ActionDeduplicator fits around actions with side effects (a vote, a post, a message), and ToolExecutionGuard around tool calls.
Defense Matrix
| Layer | Protection | Default Trigger | Error Type |
|---|---|---|---|
| CircuitBreaker | Opens after failures, cooldown before retry | 5 fails in 60s | CircuitOpenError |
| LLMProviderHealthRegistry | Skips a provider that keeps failing, in generateText() and streamText() | 402: 1 failure; 401/403: 1; 429: 3; 5xx: 5 | LLMProviderCircuitOpenError (status 503, routed to the fallback chain) |
| CostGuard | Spending caps per session/day/operation, checked by the host or by a spend budget (Cost Optimization) | $5/day per agent | { allowed: false, reason } from canAfford(); onCapReached callback; CostCapExceededError from a spend budget |
| Spend meter | A persisted allowance per account and period, in processRequest() | the host's allowance | BILLING_ALLOWANCE_EXHAUSTED error chunk |
| Spend reservations | A day's spending cap held in the caller's own tables, across processes (Cost Optimization) | the cap each admission names | CostCapExceededError (daily) from reserveSpend() and reserveOperationSpend() |
| StuckDetector | Flags repeated output or oscillation | 3 identical outputs in 5 min | { isStuck: true, reason } |
| ToolExecutionGuard | Timeout + per-tool circuit breaker | 30s timeout | { success: false, timedOut } |
| ActionDeduplicator | Prevent duplicate actions within window | 1 hr window, 10k entries | Boolean check |
Imports
The primitives are exported from the @framers/agentos package, the provider health registry from @framers/agentos/core/safety:
import {
CircuitBreaker,
CircuitOpenError,
ActionDeduplicator,
StuckDetector,
CostGuard,
CostCapExceededError,
ToolExecutionGuard,
ToolTimeoutError,
SqlSpendMeter,
} from '@framers/agentos';
import { globalLLMProviderHealth, LLMProviderHealthRegistry } from '@framers/agentos/core/safety';
Killswitches, social rate limits and an action audit log (SafetyEngine, ActionAuditLog, ContentSimilarityDedup) belong to Wunderland's social network, not to AgentOS.
References
Circuit breakers + bulkheads
- Nygard, M. T. (2018). Release It! Design and Deploy Production-Ready Software (2nd ed.). Pragmatic Bookshelf. — Foundational treatment of stability patterns: circuit breaker, bulkhead, timeout, and steady-state. The
CircuitBreakerhere implements the three-state (closed / open / half-open) machine from this book. - Fowler, M. (2014). CircuitBreaker. Martin Fowler's bliki. — Practical write-up of the circuit-breaker pattern with state-transition examples.
Cost guards + resource controls
- Chen, L., Zaharia, M., & Zou, J. (2023). FrugalGPT: How to use large language models while reducing cost and improving performance. arXiv:2305.05176. — Cost-aware LLM cascades; background for budgeting model calls.
- Chen, L., Zaharia, M., & Zou, J. (2020). FrugalML: How to use ML prediction APIs more accurately and cheaply. NeurIPS 2020. — Earlier work on prediction-API cost optimization that informed the model-cascade pattern.
Stuck detection / liveness
- Brewer, E. A. (2000). Towards robust distributed systems. PODC 2000 keynote. — The CAP theorem framing that motivates aggressive timeout + stuck-detection in distributed agent runtimes where partial unavailability is normal.
- Cantrill, B. (2006). Hidden in plain sight. ACM Queue, 4(1). — On instrumenting production systems to find pathological behaviour where it happens.
Implementation references
src/safety/runtime/CircuitBreaker.ts— three-state circuit breakersrc/safety/runtime/CostGuard.ts— per-agent cost caps in memorysrc/safety/runtime/StuckDetector.ts— repeated-output and oscillation detectionsrc/safety/runtime/SqlSpendMeter.ts— the persisted spend metersrc/core/safety/LLMProviderHealthRegistry.ts— per-provider health for the fallback router