@sema-agent/core
Version:
Stateless, task-oriented AI agent core
205 lines (204 loc) • 14.1 kB
TypeScript
import type { RunInternals, RunnerSelfSeat } from "../core/runner/contracts.js";
import type { ModelRef, TaskResult, TaskSpec, TaskStatus } from "../core/types.js";
/**
* Quality-gate cascade (design/27). Run the SAME task across a ladder of models (cheapest → strongest);
* after each rung a caller-supplied **decidable gate** judges "good enough" — pass = keep it, fail =
* escalate to the next rung. Classic FrugalGPT cascade, as a thin function over `runner.runTask`
* (no Runner-core changes).
*
* APPLICABILITY: cascade only helps when the gate is **decidable** (a schema check, a verifier model, a
* concrete assertion). Open-ended "completeness" tasks (find every bug, writing quality) have no oracle —
* the gate keeps passing the first plausible cheap answer and escalation idles. Those want breadth /
* falsification-style debate (a `team`), not a depth ladder.
*
* Cost note (design/27 §2): each rung is an independent COLD `runTask` — every escalation repays the
* full input cost. Cascade wins when (a) the cheap rung usually passes and (b) the prompt isn't so large
* that the restart tax dominates. It is NOT always cheaper than running the strongest model directly.
*
* Boundary vs teacher mode: teacher injects advice and continues the SAME session (recovery); cascade
* does independent re-runs on a model ladder (breadth). Orthogonal, not merged.
*/
/** A gate's verdict: a bare boolean, or `{ pass, diagnostics }` to attach reasoning to `attempts[]`. */
export type GateVerdict = boolean | {
pass: boolean;
diagnostics?: string;
};
export interface CascadeRung {
/** The model this rung runs on. */
model: ModelRef;
/** Optional per-rung overrides (default: inherit the task's). */
overrides?: Partial<Pick<TaskSpec, "limits" | "systemPrompt">>;
}
export interface CascadeConfig {
/** RB-473 — disclosure channel for CONTAINED host-callback failures in this scope (the RB-463
* isolation primitive's bounded first-failure per site). Absent ⇒ failures are counted only. */
onNotifyError?: (failure: import("../core/safe-notify.js").SafeNotifyFailure) => void;
/** Model ladder, cheapest → strongest. ≥1 rung; the last rung is the fallback (returned even if it doesn't pass). */
ladder: CascadeRung[];
/**
* Quality gate: given a rung's result, return whether it is good enough (true/`{pass:true}` = keep,
* false = escalate). Default: `status === "completed"` — and, if `spec.outputSchema` is set, also
* requires a valid `structuredOutput` (so a schema auto-gives "only keep a rung that actually produced
* valid structured output"; a prose-only completion escalates). The gate can be a pure check (structured
* output / fields), a verifier model, semantic agreement, etc. **A throw is treated as "did not pass"**
* (fail-open to escalate) and recorded as `attempts[].gateError` — so a broken gate can't strand the run,
* but a verifier-gate's own failure is still visible. The gate must be decidable (see APPLICABILITY).
*
* HRD-AGO-10 — a positive verdict is CONJOINED with the rung's status: it counts as a pass only when
* `result.status === "completed"`, unless {@link CascadeConfig.acceptPartial} is set. A status-blind gate
* ("the text is non-empty", "the JSON parses") could otherwise keep a blocked/failed/timeout rung, which made
* `cascadeOutcome: "passed"` disagree with the `status` the same object carries. A verdict rejected on those
* grounds is recorded as `passed: false` + {@link CascadeAttempt.statusRejected}, and the cascade escalates.
*
* @warning unredacted — the gate receives the UNREDACTED `TaskResult` (model output, structured output,
* stats); this is prose advisory, not an enforced boundary (design/54 §3.5, [46] DESIGN1). If your gate
* forwards output to an external service, ensure it has no sensitive data or sanitize first.
*/
gate?: (result: TaskResult, rung: {
index: number;
model: ModelRef;
}) => GateVerdict | Promise<GateVerdict>;
/**
* HRD-AGO-10 — the explicit opt-out from the require-completed conjunction above. Two states, no middle:
* - **omitted / `false` (default)** — a {@link gate} pass counts only on a `completed` rung. Unfinished work
* is escalated, so `cascadeOutcome: "passed"` always implies the returned rung actually finished.
* - **`true`** — the gate's verdict alone decides, so a deliberately partial answer can be kept (a blocked
* rung whose partial output IS the deliverable, a timeout whose streamed prefix is good enough). The
* returned `status` still reports the truth — this only says the gate is allowed to accept it.
*
* Ignored by the default gate, which requires `completed` in either case.
*/
acceptPartial?: boolean;
/** Max escalations (rungs run ≤ maxEscalations + 1). Default = `ladder.length - 1` (the whole ladder). */
maxEscalations?: number;
/** Stop escalating once cumulative cost reaches this; return the current best. Optional. */
costCeilingMicroUsd?: number;
/**
* Overall wall-clock ceiling for the whole cascade (per-rung time is `spec.limits.timeout`). Optional.
*
* HRD-AGO-7 — this is a REAL deadline over the run, not just a gate on starting the next rung: each rung is
* dispatched with an `AbortSignal` that fires at `start + totalTimeoutMs` (combined with the caller's own
* `spec.signal` when there is one), so a rung already in flight when the budget runs out is ABORTED. What
* "aborted" buys is bounded by the workload's cooperation with cancellation — the brain request is cancelled
* and a tool that honors its `ToolExecuteContext.signal` stops at once, but a tool that ignores its signal
* still runs to its own completion (the run then assembles as failed the moment it returns). Cleanup on a
* cut: the rung's result is NOT gated (truncated work must never pass), its session IS released (nothing
* pins it — a durable pause is handled earlier and returns instead), the attempt is recorded with
* `timedOut: true`, and the ladder stops (`cascadeOutcome: "exhausted"`) rather than escalating past a
* deadline that has already passed.
*
* A budget already spent AT ENTRY (`0`, negative, or a caller that measured elapsed time itself) dispatches
* NOTHING and returns the cascade's own terminal: `status: "failed"`, `errorCode:
* "cascade.budget_exhausted"`, empty `attempts`, `finalRung: -1`.
*/
totalTimeoutMs?: number;
/** Per-rung callback (observability). */
onRung?: (info: {
index: number;
model: ModelRef;
passed: boolean;
result: TaskResult;
}) => void;
}
export interface CascadeAttempt {
index: number;
model: ModelRef;
passed: boolean;
/** This rung's OWN+nested spend. ABSENT when the rung ran unpriced (RB-368 knownness batch-4
* F4) — a fabricated 0 here both misreported the attempt and let escalation ride under a finite
* ceiling the engine could not actually enforce. */
costMicroUsd?: number;
status: TaskStatus;
/** The rung's own failure code (e.g. "auth") if its `runTask` failed — distinct from "gate rejected it". */
errorCode?: string;
/** Set when the gate THREW (vs returned false) — surfaces a broken verifier-gate (expired key, etc.). */
gateError?: string;
/** HRD-AGO-7: this rung was CUT IN FLIGHT by {@link CascadeConfig.totalTimeoutMs}. Its result is truncated
* work, so it was never handed to the gate (`passed` is always false) and the ladder stopped here. */
timedOut?: true;
/** HRD-AGO-7: the CALLER's own `spec.signal` aborted during this rung (distinct from the cascade deadline).
* Same handling — no gate, no escalation onto an already-aborted signal. */
cancelled?: true;
/** HRD-AGO-10: the gate returned a PASS but the rung had not `completed`, so the verdict did not count
* (`passed: false`, escalate). Set only in the default require-completed mode — never with
* {@link CascadeConfig.acceptPartial}. */
statusRejected?: true;
/** Optional reasoning the gate attached to its verdict. */
diagnostics?: string;
}
export interface CascadeRunResult extends TaskResult {
/**
* The cascade's conclusion, **distinct from the inherited `status`**: `"passed"` = some rung passed the
* gate; `"exhausted"` = ladder/guards ran out with no rung passing (the last rung's result is returned,
* and its `status` may still be "completed" — judge the cascade by `cascadeOutcome`, not `status`).
*/
cascadeOutcome: "passed" | "exhausted";
/** F4: present when a finite `costCeilingMicroUsd` was configured but some rung's spend was unpriced —
* the ceiling could not be enforced over unknown cost, so the ladder STOPPED escalating (fail-closed)
* instead of riding an unbindable budget. Judge spend by the absent cost fields, not by 0. */
costCeilingUnenforceable?: true;
/** True if the returned result came from a rung after the first (i.e. an escalation happened). */
escalated: boolean;
/** Which rung (0-based) produced the returned result — or `-1` when NO rung was dispatched (an already
* spent {@link CascadeConfig.totalTimeoutMs}); `attempts` is then empty and the result is the cascade's
* own synthetic `cascade.budget_exhausted` terminal, not a rung's. */
finalRung: number;
/** Per-rung audit detail (cost, pass/fail, why). Each attempt's `costMicroUsd` includes that rung's nested
* (delegated) cost; the top-level `stats.costMicroUsd` is the sum of the rungs' OWN cost (nested totals live
* in `stats.nested.costMicroUsd`), so it is NOT the plain sum of `attempts[].costMicroUsd`. */
attempts: CascadeAttempt[];
}
/**
* Side-effect note: `runCascade` runs the task up to N times. If your tools have non-idempotent effects
* (`effect: "write"`), those execute once PER rung — ensure idempotency, or gate before the side effect
* (e.g. a structured-output gate). `TaskSpec` never guaranteed idempotency; cascade makes it explicit.
*/
export declare function runCascade(runner: RunnerSelfSeat, spec: TaskSpec, config: CascadeConfig,
/**
* The TRUSTED, run-scoped internals channel — the same object `Runner.runTask` takes as its second
* argument — forwarded to EVERY rung under the composition forward contract (identity, with the one
* placement-axis exception stated where the shape is named in `src/index.ts`).
*
* WHY THE SEAT EXISTS: `runCascade` returns a `TaskResult` and DRAINS each rung's event stream
* internally, so the host never holds a `TaskStream` for any rung. Without this parameter a
* deployment observes nothing a rung did — tool beats, forwarded child progress, the resolved
* workspace — only the per-rung {@link CascadeAttempt} audit rows and the final result. An
* observation sink here (`onActivity`, `onForwardEvent`, `onWorkspaceResolved`) is the only way in.
*
* ATTRIBUTION: rungs run SEQUENTIALLY with the SAME object, so one sink receives the rungs' beats
* CONCATENATED in ladder order. `ToolActivity` carries no rung identity — two rungs running the same
* tool are indistinguishable — so the boundary a host keys on is {@link CascadeConfig.onRung}, which
* fires only after its own rung's beats have been delivered (the sole rung ending without one is a
* durable pause, which returns immediately). Two honesty notes: SUBAGENT events forwarded through
* `onForwardEvent` carry their own `sourceTaskId`, and a BACKGROUND child outlives the rung that
* spawned it — its frames can arrive inside the next rung's segment.
*
* FAILURE-ISOLATED, by the same primitive as {@link CascadeConfig.onRung}: engine-invoked through a
* run-scoped safe notifier — a throwing `onActivity` is contained (first failure per site disclosed
* via `console.warn`, later ones counted) and never faults the rung. Uncontained, the fault
* AMPLIFIES: the faulted rung is rejected by the gate, the cascade escalates, and a non-idempotent
* tool the rung had ALREADY executed runs AGAIN on the next rung — the MEASURED HAZARD pin holds
* containment.
*
* FORWARDED WHOLE, NOT FILTERED: every field rides every rung, including the ref-valued ones
* (`peerSelfRef`, `ownOrgAdmissionRef`) whose own contract is one ref per RUN — wiring those here
* makes the ladder's rungs share one carrier. The seat is meant for observation sinks; anything
* else is a choice the caller is making explicitly, not a default this layer applies.
*
* `isolation: "worktree"` in particular rides through here, where the verification gate REFUSES it —
* an asymmetry with a reason. That field mints a NEW workspace per RUN: the gate's runs are one
* artifact judged and repaired by several legs, so separate workspaces would have the verifier
* grading files the implementation never wrote to, while a ladder's rungs are INDEPENDENT COLD
* RE-RUNS, so one workspace per rung is the coherent reading. What a host must then know: the
* returned result is ONE rung's, its work lives in THAT rung's workspace, and no field of the result
* names it — `onWorkspaceResolved` is the only place the path appears, once per rung in ladder
* order. Whether it is still readable afterwards is the FACTORY's contract: each rung's
* factory-minted env is torn down at the end of that rung's run.
*
* Absent ⇒ every rung is dispatched with `undefined`, with ONE design/380 O1② placement delta either
* way: when neither `placementRoot` nor `rootSessionId` is present, rungs after the first get a
* fresh shallow copy carrying `placementRoot` = the first rung's resolved sessionId (the ladder's
* placement fixed point); every other field still rides by the same reference, and an internals
* object that already carries a fixed point is forwarded verbatim.
*/
internals?: RunInternals): Promise<CascadeRunResult>;