@sema-agent/core
Version:
Stateless, task-oriented AI agent core
111 lines (110 loc) • 7.13 kB
TypeScript
/**
* 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;
}