@sema-agent/core
Version:
Stateless, task-oriented AI agent core
268 lines (267 loc) • 19.9 kB
TypeScript
/**
* design/164 件五 — DEPLOYMENT-level usage governance (the seam, its arithmetic, and the in-memory
* reference implementation).
*
* This is a different axis from `TaskSpec.limits`: a task limit is the allowance ONE task asked for,
* while a usage window is the allowance an OPERATOR grants a principal (or a whole deployment) ACROSS
* tasks — the shape the industry converged on for account-level governance (a first-use window plus a
* long rolling one). Core never invents a window: with `RunnerDeps.usageWindows` unset, nothing here
* runs and no ledger is written.
*
* The arithmetic lives HERE, as pure functions over a per-key {@link UsageWindowRecord}, and every
* store implementation is a persistence shell around them — that is what makes the in-memory and file
* backends equivalent by construction rather than by two hand-written copies that drift.
*/
/**
* One governed window. Its fields are allowances, not hints: `windowMs` is the window's WIDTH and
* `maxTokens` / `maxCostUsd` the two CEILINGS it admits work under — each optional on its own, at least
* one required (a window with neither is refused at the door). `anchor` picks between the two window
* shapes that actually exist in the wild:
* - `"first-use"` — the window OPENS at the key's first charge and lasts `windowMs`; when it lapses with
* no further use, the next charge opens a fresh one. This is the "5 hours from when you started"
* shape: bursty use is admitted at full width, and an idle key is never penalized for old traffic.
* - `"rolling"` — a continuously sliding window: the charge from `now − windowMs` to `now`. This is the
* "7 days" shape: it never resets, so sustained use is bounded no matter how it is spread.
*/
export interface UsageWindow {
/** Window width in ms. Must be finite and > 0 (a zero-width window admits nothing and expires
* instantly — refused at the door rather than silently governing every task to death). */
windowMs: number;
/**
* Tokens the window admits before it is exhausted. Must be finite and >= 0 WHEN PRESENT; `0` is a real,
* always-full window (an operator lock-out), not "unset" — absence is the only way to say "this window
* does not govern tokens".
*
* OPTIONAL since the $ ceiling below became a peer rather than an addition: a deployment governed by
* SPEND alone would otherwise have had to declare a token ceiling no run could reach, i.e. a number
* nobody chose sitting in the field this door exists to keep honest. Either ceiling alone is a complete
* declaration; a window that declares NEITHER is refused (`config.usage_window_invalid`) rather than
* admitting everything silently.
*/
maxTokens?: number;
/**
* The OTHER ceiling: the MONEY the window admits before it is exhausted, in absolute USD — the
* same quantity and unit `TaskLimits.maxCostUsd` names, one governance level up (a task's own allowance
* vs the allowance an operator grants a principal across tasks). Must be finite, >= 0, and must still be
* finite once converted to the ledger's micro-USD unit (`× 1e6`, i.e. below ~1.79e302 USD — an Infinity
* threshold is not the declared ceiling in the unit the ledger counts, so such a window would read as
* governed while enforcing nothing it was given, and is refused at the door). `0` is a real,
* always-full window, exactly as it is for `maxTokens`.
*
* The two ceilings are INDEPENDENT and every DECLARED one binds: a window is exhausted when either its
* tokens reach `maxTokens` or its cost reaches `maxCostUsd` — whichever fills first — and a ceiling that
* is absent is not a ceiling at all (never a zero, never an infinity: it simply does not participate).
* Absent (the default, and every pre-existing deployment) means THIS window governs tokens only — its
* own reading never asks for or enforces a cost. Whether cost is tracked/recorded at all is a
* DEPLOYMENT-level fact, not a
* per-window one (the `UsageSlot.costMicroUsd` / `UsageBucketRow` in-presence condition: "while the
* deployment governs at least one `maxCostUsd` window"): in a MIXED declaration the shared ledger
* rows carry cost under every window's bucket, and only a deployment with NO $ window anywhere keeps
* its rows byte-identical to the pre-cost-arm shape.
*
* **Requires a PRICED run.** Cost is not a number the engine can invent: a run whose model has neither a
* `RunnerDeps.pricing` entry nor a `Model.cost` declaration produces NO cost figure at all (not a zero),
* so a $ ceiling over it is unevaluable and the task is refused at the door
* (`config.usage_window_unpriced`) rather than charged a fabricated 0 that would let the ceiling
* silently stop applying.
*/
maxCostUsd?: number;
/** Which of the two window shapes above this is. */
anchor: "first-use" | "rolling";
}
/** One charge against a key: `tokens` charged at epoch ms `at`. The unit of the rolling lane's ledger. */
export interface UsageSlot {
at: number;
tokens: number;
/** The charge's cost in integer micro-USD. **In-presence condition:** written only while the deployment
* governs at least one {@link UsageWindow.maxCostUsd} window AND the charge had a cost figure at all — a
* token-only deployment's ledger rows are byte-for-byte what they were before the cost arm existed, and
* so is the row for spend nothing could price. Absent therefore reads as "no money recorded here": a row
* written BEFORE a $ window was declared, or one whose model had no price table, contributes 0 to the
* money lane rather than back-dating a number nobody recorded. */
costMicroUsd?: number;
/** This charge's spend could NOT be priced (see {@link UsageWindowStore.charge}'s `null` arm).
* **In-presence condition:** only ever `true`, only on a row written while a $ window was governed and
* nothing could price the spend. It is what keeps "no money here" apart from "no money spent here": a
* window holding one of these reports a money figure that is a LOWER BOUND, and says so
* ({@link UsageWindowReading.costUnknown}). */
costUnknown?: true;
}
/** The OPEN first-use window for one `windowMs` on one key. Rows for other widths coexist, so a
* deployment governing 5h and 7d windows keeps one row per width rather than one blended counter. */
export interface UsageBucketRow {
/** The width this row belongs to — the row's identity, matched against {@link UsageWindow.windowMs}. */
windowMs: number;
/** Epoch ms the window opened (its first charge). */
openedAt: number;
/** Tokens charged into it since it opened. */
tokens: number;
/** Integer micro-USD charged into it since it opened. Same in-presence condition as {@link UsageSlot.costMicroUsd}. */
costMicroUsd?: number;
/** Some spend in this open window could not be priced. Same meaning and in-presence condition as
* {@link UsageSlot.costUnknown}; sticky for the life of the open bucket. */
costUnknown?: true;
}
/** Everything one key's governance state consists of: the rolling lane's slots and the first-use lane's
* open buckets. Persisted verbatim by every backend (plain JSON, no methods). */
export interface UsageWindowRecord {
slots: readonly UsageSlot[];
buckets: readonly UsageBucketRow[];
}
/** The state of one window for one key, as of a given instant. */
export interface UsageWindowReading {
/** The window this reading answers for (verbatim, so a caller can name the offending allowance). */
window: UsageWindow;
/** Tokens the window currently holds. */
tokens: number;
/** Integer micro-USD the window currently holds. **In-presence condition:** present exactly when
* `window.maxCostUsd !== undefined` — a reading for a token-only window is byte-identical to what it
* was before the cost arm existed, and a MISSING value on a $ window means the store did not evaluate
* the ceiling at all (refused loudly by {@link usageRetryAfterMs}, never read as an open ceiling). */
costMicroUsd?: number;
/** The window holds spend nothing could price, so `costMicroUsd` is a LOWER BOUND rather than the total.
* **In-presence condition:** only ever `true`, and only on a window that declares `maxCostUsd`. The
* ceiling still binds on what IS countable (an under-count can only postpone exhaustion, never invent
* it), and the engine announces the gap on every run that reads it — the alternative, treating the
* whole window as exhausted until the unpriced charge ages out, would lock a key out for as long as
* `windowMs` over one degraded turn, which is a larger outage than the gap it answers. */
costUnknown?: true;
/** The window admits no further work: (when the window declares one) `tokens >= window.maxTokens`, OR
* (when the window declares one) `costMicroUsd >= window.maxCostUsd` — the two ceilings are independent
* and every DECLARED one binds. An undeclared axis never binds, however much the window holds on it:
* `tokens` is still reported for a money-only window (what it holds), it is just not a ceiling there. */
exhausted: boolean;
/** Ms until this window next frees capacity: for `first-use`, when the open window lapses; for
* `rolling`, when its OLDEST in-window slot ages out (which frees that slot's tokens, not
* necessarily enough for a whole task — a driver that resumes then and re-suspends is behaving
* correctly, and each attempt gets a fresh, later hint). `0` when the window is not exhausted. */
retryAfterMs: number;
}
/**
* The cross-task token ledger the governance windows are evaluated against. Deliberately two methods and
* no query language: core charges what a task spent and asks what the key's windows hold — everything
* else (retention, sharding, multi-writer safety) belongs to the backend.
*
* **Keying**: the caller passes a key that is the PRINCIPAL when the task declares one, and a single
* shared global key otherwise (see `GLOBAL_USAGE_KEY`) — so a multi-tenant deployment governs per tenant
* and a single-tenant one governs the deployment, with no branch in either.
*/
export interface UsageWindowStore {
/**
* Charge `tokens` to `key` at epoch ms `at`. `windows` is the deployment's live window set: the store
* needs it to know which first-use buckets to keep open and how far back the rolling lane must
* remember, so the ledger stays bounded instead of growing for the life of the deployment.
* Called once per accounting point; never with a negative or non-finite `tokens`.
*
* `costMicroUsd` is the same charge's MONEY, in integer micro-USD, or `null` when the spend is real but
* NOTHING could price it. **In-presence condition:** one of the two is supplied exactly when the
* deployment governs at least one {@link UsageWindow.maxCostUsd} window — a token-only deployment is
* called with the pre-cost-arm argument list and must behave identically.
*
* A store that governs $ windows MUST FORWARD it (into `chargeUsageRecord`, or into its own equivalent)
* and answer {@link UsageWindowReading.costMicroUsd} from what it persisted. A decorator written against
* the pre-cost-arm signature drops the argument silently — which is why the arithmetic refuses an absent
* cost outright (`usage_window.store_cost_unanswered`) instead of folding it to a 0 that would leave the
* money ceiling open forever with nothing able to notice.
*
* IMPLEMENTER SELF-CHECK — three questions, in order. The in-presence condition above is a
* DEPLOYMENT-wide property of the `windows` argument, not a per-window one, and a store that reasons one
* window at a time is exactly where it gets missed:
* 1. Does this call's window set carry ANY window declaring `maxCostUsd`? Then this is a COST charge
* for every row it writes — the rolling slot is ONE row every rolling window reads, and a first-use
* bucket is ONE row per width shared by every window of that width — so there is no "cost only on
* the $ windows" split to implement. A row written cost-less because a token-only window happened
* to be the one in view is a row the $ window later reads as unpriced.
* 2. Was the spend real but unpriceable (no rate for the model, a provider that answered no usage)?
* Then carry the `null` through AS `null`, never as `0`: `0` asserts "this charge cost nothing" and
* silently raises the remaining allowance, while `null` is what lets the row say so and surfaces as
* {@link UsageWindowReading.costUnknown}, keeping the reported total honest as a LOWER BOUND.
* 3. Cannot answer either — the argument never reached this layer? Then leave it `undefined` and let
* the charge be refused loudly. A guessed 0 is the one outcome the money ceiling cannot survive.
*/
charge(key: string, tokens: number, at: number, windows: readonly UsageWindow[], costMicroUsd?: number | null): Promise<void>;
/** Read `key`'s state for each window as of `now`, in the order the windows were given. */
read(key: string, windows: readonly UsageWindow[], now: number): Promise<readonly UsageWindowReading[]>;
}
/** The ledger key for a task that declares NO principal: one shared bucket that governs the whole
* deployment. Distinctive on purpose — it must never collide with a real principal id. */
export declare const GLOBAL_USAGE_KEY = "__usage_global__";
/** A key with no history yet. Frozen: it is handed to callers as a starting value and must not be
* mutated into a shared-state bug. */
export declare const EMPTY_USAGE_WINDOW_RECORD: UsageWindowRecord;
/**
* Validate `RunnerDeps.usageWindows` at the door and return it unchanged — the `resolveTaskLimits`
* posture: an unevaluable governance window is not a governance window, and folding it to a default
* would govern a deployment by a number nobody chose. Refuses with `config.usage_window_invalid`.
*/
export declare function resolveUsageWindows(windows: readonly UsageWindow[] | undefined): readonly UsageWindow[] | undefined;
/** Does the deployment govern MONEY at all? The one predicate that decides whether a cost figure is asked
* of the engine, written into the ledger, or answered in a reading — so a token-only deployment stays
* byte-for-byte what it was before the cost arm existed. */
export declare function windowsGovernCost(windows: readonly UsageWindow[]): boolean;
/**
* Fold one charge into a key's record and return the NEW record (pure — the input is never mutated, so a
* store that keeps records in a Map cannot be corrupted by a half-applied charge).
*
* Retention is part of the fold rather than a separate sweep: slots older than the widest ROLLING window
* can never be read again, and a first-use bucket for a width the deployment no longer governs can never
* be read again either, so both are dropped here. A record therefore stays bounded by (charges within the
* widest rolling window) + (one row per governed first-use width).
*
* `costMicroUsd` rides the same fold as `tokens` (integer micro-USD, never a float USD: the ledger sums
* thousands of charges and a float would accumulate error into a money ceiling), and it is a THREE-state
* argument while the deployment governs a $ window:
* - a NUMBER — the charge's cost, folded into the money lane;
* - `null` — the tokens are known but their cost is NOT (an unpriced model served the spend). The row is
* written with its tokens and NO cost field: the token ceiling stays exact, and the money lane counts
* nothing rather than a fabricated 0. The engine refuses such a run anyway — this keeps the ledger
* honest about the spend that already happened before the refusal;
* - `undefined` — REFUSED. Core always supplies one of the two above when a $ window is governed, so an
* absent argument means some store in the chain (a pre-cost-arm decorator, most likely) dropped it, and
* folding it to 0 would leave the money ceiling permanently open with nothing able to detect it.
* With NO $ window governed the argument is ignored entirely and rows keep their pre-cost-arm bytes.
* Rows that already carry a cost keep it, so removing and re-adding a $ window neither loses the history
* nor rewrites rows that predate it.
*/
export declare function chargeUsageRecord(record: UsageWindowRecord, tokens: number, at: number, windows: readonly UsageWindow[], costMicroUsd?: number | null): UsageWindowRecord;
/**
* Read a key's record against the governed windows as of `now` (pure). The `first-use` lane reads the open
* bucket; the `rolling` lane sums the slots inside `(now − windowMs, now]`.
*/
export declare function readUsageRecord(record: UsageWindowRecord, windows: readonly UsageWindow[], now: number): UsageWindowReading[];
/**
* The TIGHTEST binding constraint across a set of readings: how long the caller must wait before ANY
* window would admit work again. `undefined` when nothing is exhausted. The MAXIMUM (not the minimum) of
* the exhausted windows' hints — resuming when the shortest one clears would immediately re-suspend on
* the longer one.
*
* It is also the ONE choke point every governance read in the engine goes through, so the integrity
* checks live here, in two arms:
* - **coverage** (when the caller supplies the declared `windows`): one reading per declared window,
* in the order the windows were given, with the window echoed verbatim on the fields that name the
* allowance (`windowMs`/`anchor`/`maxTokens`/`maxCostUsd`). The per-reading arm below is
* structurally blind to a reading that is MISSING — a decorator store that maps only the windows
* it recognizes, reorders the answer, or echoes a normalized copy with `maxCostUsd` stripped would
* otherwise un-govern a declared ceiling with zero symptoms. Every engine read passes its windows
* through; the parameter stays optional so a bare-readings caller keeps the pre-coverage face.
* - **per-reading**: a reading for a window that DECLARES `maxCostUsd` while carrying no
* `costMicroUsd` means the store never evaluated the money ceiling — its `exhausted` verdict then
* covers the token axis alone.
* Both arms refuse with ONE code (`usage_window.store_cost_unanswered`) rather than read as an open
* ceiling — an unanswered window and an answered-without-cost window are the same fact ("the store
* never proved it evaluated the declared ceiling"): a governance ceiling that silently stops applying
* is worse than one that never existed, which is the same posture the file backend takes when its
* ledger is unreadable.
*/
export declare function usageRetryAfterMs(readings: readonly UsageWindowReading[], windows?: readonly UsageWindow[]): number | undefined;
/**
* Process-local {@link UsageWindowStore} reference implementation. Governs correctly within ONE Runner
* process and loses its ledger on restart — the right choice for tests and single-process deployments,
* and explicitly NOT the right choice for a fleet (use the file backend, or a real database, when the
* governance must survive a restart or span replicas).
*/
export declare class InMemoryUsageWindowStore implements UsageWindowStore {
private readonly records;
charge(key: string, tokens: number, at: number, windows: readonly UsageWindow[], costMicroUsd?: number | null): Promise<void>;
read(key: string, windows: readonly UsageWindow[], now: number): Promise<readonly UsageWindowReading[]>;
}