UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

205 lines (204 loc) • 14.1 kB
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>;