UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

268 lines (267 loc) • 19.9 kB
/** * 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[]>; }