UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

111 lines (110 loc) • 7.13 kB
/** * The task LIMITS: the allowance axes a caller sets on one run (turns, tokens, cost, wall-clock, the * tool-call and idle bounds, the per-model overrides). Its own module rather than a section of * `task-spec.ts` because it is the one shape a caller edits without touching anything else in the * spec. Layer 0 vocabulary; `types.ts` re-exports the name, so no consumer's import changes. */ import type { ModelRef } from "./model-seat.js"; /** * design/164 — the task's complete limit surface (see {@link TaskSpec.limits}). * * **Window semantics.** `maxTokens` / `maxCostUsd` / `maxWalltimeMs` are PER-SLICE windows, not * whole-task totals: a task that opted into {@link TaskSpec.resourceSuspend} suspends when a window is * exhausted and its resume gets the NEXT window of the same size. Whole-task ceilings live on * `resourceSuspend` (`totalTokens` / `totalBudgetUsd`) and are frozen on the ledger, so a resume can * never refresh them. * * **Two-tier hit semantics.** When a window is exhausted: * - no `resourceSuspend` (the default) ⇒ a LOUD TERMINAL: `status:"failed"` with * `limits.max_tokens_exceeded` / `limits.max_cost_exceeded` / `limits.max_turns_exceeded` / * `limits.max_walltime_exceeded`, salvaged text attached where there is any. This is the * "must have a definite outcome by the deadline" shape — express it by NOT opting in. * - `resourceSuspend` set and the durable infrastructure present ⇒ a resumable `suspended` checkpoint. * * **Priority.** Several axes may be armed at once; the first to trip stops the run. When more than one * trips at the same turn boundary the attribution order is tokens > cost > turns > walltime. */ export interface TaskLimits { /** * Cumulative prompt+completion tokens this slice may spend. The PRIMARY budget axis. Unset ⇒ no token * ceiling. Must be a finite, non-negative number (a bad value is a fail-loud config error, never folded * to a default) — see the two-tier hit semantics on {@link TaskLimits}. */ maxTokens?: number; /** * Cumulative spend ceiling for this slice, in USD (requires `model.cost` or `RunnerDeps.pricing`). * Checked pre-call and at each turn boundary. Unset ⇒ no cost ceiling. Money and tokens are two * genuine mental models and both are kept; documentation leads with `maxTokens`. * * NOTE: the pre-call check is **best-effort** — it estimates the first call's input from the objective * length only (it cannot see the assembled system prompt / history / memory), so it may under-estimate * and let an oversized first turn run; the turn-boundary gate then catches it after the spend. */ maxCostUsd?: number; /** Turn ceiling for this slice. Unset ⇒ no turn ceiling. `0` is the explicit "unbounded" sentinel * (kept for callers that write it deliberately); any other non-positive / non-finite value is a * fail-loud config error. */ maxTurns?: number; /** * Wall-clock ceiling for this slice, in MILLISECONDS. A PER-SLICE ACTIVE clock: it starts at the * slice's first turn on the monotonic clock, suspended time does not count, and a resume opens a * fresh window (a new process, a new anchor — no cross-process clock is ever compared). Unset ⇒ no * wall-clock ceiling, which is the default: time answers "how long did it take", not "how much work * is this allowed to be", so it is the axis of last resort. */ maxWalltimeMs?: number; /** * design/119 #2: per-REQUEST output-token cap (the provider `max_tokens` / `max_completion_tokens` * field). NOT a budget — it bounds each model reply, while {@link maxTokens} bounds the whole slice. * Unset ⇒ the brain falls back to `model.maxTokens` (both families; the OpenAI lane omits the * wire field — provider default — only when `model.maxTokens` is also absent). A reply * that hits it stops with `length` and the loop's truncated-output auto-continue takes over. * HARD bound on Anthropic budget-thinking models too: a cap too small to host a legal thinking * budget (< 2048) skips thinking for the request rather than silently raising the cap. */ maxOutputTokens?: number; /** * design/164 — the LIMIT-APPROACH notice. While any of `maxTokens`/`maxCostUsd`/`maxTurns` is armed, * the engine injects at most two one-shot reminders at turn boundaries: one when the highest axis * ratio crosses the first threshold ("start converging"), one when it crosses the second ("deliver * now"). Both are advisory prompt frames — they never stop the run, and they never fire on a * resource-suspend task (a slice boundary is not a delivery deadline). * * Default ON whenever an axis is armed; `false` switches it off. `{ at: [first, second] }` overrides * the thresholds (each a fraction in `(0, 1]`, first ≤ second). */ approachNotice?: false | { at?: [number, number]; }; /** Cancel an in-flight stream the moment cumulative cost/tokens cross their window. Defaults to `true` * when {@link maxCostUsd} is set (hard control), `false` otherwise. */ budgetStreamCancel?: boolean; /** * **Near-budget degradation** (design/21 §5C). When cumulative cost reaches `atCostFraction` of the * effective cost window, **switch the active model to the cheaper `to`** for the rest of the task to * stretch the remaining budget — accepting lower quality instead of stopping at the ceiling. One-way * (never switches back); sets `TaskResult.degraded = { …, reason: "budget" }`. Requires * {@link maxCostUsd} (the fraction is of it); ignored otherwise. This is the proactive, Runner-level * complement to the reactive brain-level `createDegradingBrain` (rate_limit / breaker-open). */ degrade?: { to: ModelRef; atCostFraction: number; }; /** * The OUTER wall on how long the engine waits for a single brain * call, in ms. Default {@link import("../brain/timeout.js").BRAIN_CALL_GUARDRAIL_DEFAULT_MS} * (one hour); `false` or `0` switches it off; any other non-positive / non-finite value is a * config error (fail-loud, never folded to a default). Overrides `RunnerDeps.brainCallGuardrailMs`. * * This is a GUARDRAIL, not a budget: it answers "is the system hung", so unlike every axis above it * is armed by default, and it does NOT depend on the brain cooperating — on expiry the engine aborts * the signal it handed the brain AND stops awaiting the call, ending the task fail-loud with * `errorCode` `"brain.call_guardrail_timeout"`. That is what makes it the backstop for a custom * `Brain` that ignores `options.signal` or never settles. * * It is deliberately COARSE. A task that also sets {@link maxWalltimeMs} runs both clocks in parallel * and the tighter one expires first — the guardrail only shows up when everything finer failed to bind. */ brainCallGuardrailMs?: import("../brain/timeout.js").BrainCallGuardrailKnob; }