UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

285 lines (284 loc) • 19.9 kB
/** * The terminal cause — the ONE record of how a run ended (`TaskResult.terminal`), its closed kind set, * the one disposition table over that set (which cause a resume journal may replay) and the admission * read a journal entry's cause passes through. Layer 0 vocabulary, beside `gate-outcome.ts` (the record * of one gate pass) and `pause-registry.ts` (the pause gate × pending-action pairing a paused cause * points at): no runtime import above this layer, so any module may read the words. * * The plane-word projection a face that still speaks `status` needs lives in * `runner/terminal-projection.ts` — the one cause→plane derivation. */ import type { AssertAllKeysHandled } from "./ask-origin.js"; import type { ProviderCondition, ProviderConditionSource } from "../brain/provider-dialect.js"; import type { RequestRoute } from "../brain/request-route.js"; import type { ModelOutputInvalidCause, OutputLimit } from "../internal/llm.js"; /** * WHY a run ended — ONE tagged cause, and the ONLY terminal record a {@link TaskResult} carries. The run * loop's terminal arbitration used to decide a `status` word plus seven optional plane fields one by one * across an if/else-if ladder over competing optional flags, and the legal combinations * ("`status:"suspended"` ⇔ a token is present", "a review pause and an approval pause are never both set") * lived only in that ladder's order. Here they are the SHAPE: a paused run carries its token; a completed * run cannot. Branch on `kind`; the five-word {@link TaskStatus} a cause reads as (a `paused` cause reads * its gate kind's registry row) is derived by {@link import("./runner/terminal-projection.js").terminalProjection}, * the one cause→plane derivation, for the faces that still speak the word. * - `completed` — the run finished (a person's clean halt included — see `TaskResult.haltedByUser`). * - `failed` — a limit, a provider failure, an abort, an invalid output… * - `blocked` — the run ended without doing the work, and `detail` says by whom: ABSENT = the AGENT reported, * through `ReportBlocked`, that it cannot finish, and why; `detail.kind === "prompt_blocked_by_hook"` = a * `userPromptSubmit` hook kept the prompt from being submitted (see {@link TerminalBlockedDetail}). A TERMINAL * cause: the leg is over and it waits for nobody (nothing to resume, no token). The waiting-on-a-person * states are the `paused` cause's two status words — `suspended` (a pre-action approval of a tool * call) and `needs_review` (a review pause); the task-registry face spells both as `parked`. * - `paused` — a durable pause committed a checkpoint and the run is resumable. * A `GateOutcome` is the record of ONE gate pass (per call); a `TerminalCause` is the record of ONE run * (per leg); a paused run's `gate` and the park row's `gate.kind` are the same word, joined by the token. */ export type TerminalCause = { kind: "completed"; /** * A machine-readable account beside a completion, for the one shape that completes yet may be short: * `finish_reason_unknown` — the final turn's provider ended it with a stream-end word the brain does not read * (`AssistantMessage.finishReasonUnknown`); the turn was kept as the model left it, and the run announced the * word (`route.finish_reason_unknown`). PRESENCE: only when the final assistant message carried that seat and the * run completed on it; every other completion leaves it absent. */ detail?: TerminalCompletedDetail; } | { kind: "failed"; /** * Machine-readable failure code when the failure carried one — e.g. a `SessionError.code` such as * `"conflict"` (a cross-instance write lost the optimistic lock), or a Node error code. Lets a * caller branch programmatically (e.g. evict a stale cache + retry on `"conflict"`) instead of * matching `message` strings. Absent only for the failures that never had one (an abort with no * limit axis, no assistant message produced). * * Terminal codes use a **dotted namespace** so a caller can prefix-match a whole class: * `"limits.max_tokens_exceeded"` / `"limits.max_cost_exceeded"` / `"limits.max_turns_exceeded"` / * `"limits.max_walltime_exceeded"` / `"config.limit_invalid"` / `"config.limit_unknown_key"` / * `"config.attachment_invalid"` (a mode-valued `TaskSpec.attachments` member outside its closed * set, refused at the same door as the limits) (e.g. `code.startsWith("limits.")`). Brain codes * (`auth`/`network`/`rate_limit`/…) and `"conflict"` remain flat. * * Two codes name the EXTERNAL stop causes — neither is a `limits.` code, because neither is an * allowance the task chose, and a caller that retries on `limits.*` should NOT treat these the same: * - `"env.lifetime_expired"` — the execution environment's platform lifetime ran out and the run * could not be suspended durably. Retrying needs a NEW environment, not a smaller budget. * - `"usage.window_exhausted"` — a deployment usage-governance window is full (see * `RunnerDeps.usageWindows`). Reported at task ENTRY (nothing ran) or after a suspend was * impossible; the wait hint rides the thrown error's `retryAfterMs` (delivered through * `RunnerDeps.onError`) and the message text. Retrying before the window frees is refused again. * Their config-time siblings are `"config.env_lifetime_invalid"` / `"config.usage_window_invalid"`. * A governance window with a MONEY ceiling adds two more, both of which say "the ceiling could not * be evaluated" rather than "the ceiling was reached" — neither is retryable without a config change: * - `"config.usage_window_unpriced"` — a `UsageWindow.maxCostUsd` over a run with no cost figure * (no `RunnerDeps.pricing` entry and no `Model.cost`). * - `"usage_window.store_cost_unanswered"` — the wired ledger does not carry the money arm. * A NESTED orchestrator that met a durable pause it cannot drive stamps `"unexpected.suspended"` / * `"unexpected.needs_review"` and carries the pause on {@link nestedPause}. */ code?: string; /** Human-readable failure text. */ message?: string; /** * A machine-readable account of the failure beside `code` — the fact the failure's mint point stated, projected * once ({@link TerminalFailureDetail}; branch on `detail.kind`, never on `message`): * - `provider_rejected` — the provider REFUSED the request as sent (a 4xx answer) and that refusal named this * result. `status` is the refusal's HTTP status (`TaskResult.apiFailure.status`, the same value); `code` the * provider's own word (`AssistantMessage.apiErrorCode`); `route` the request's route. A run whose * higher-ranked cause named the result, a 5xx, a connect / stream failure and an in-band error inside a 200 * carry none. A 401 / 403 (credentials) and a 429 (rate limit) are refusals too: branch on `status`. * - `coded` — the run threw a coded error (this `code`) whose throw site stated a structured `detail`. * - `model_output_invalid` — the model's final turn failed because its tool calls could not be executed. * PRESENCE: only when the arm that named this result has such a fact to project; absent otherwise. */ detail?: TerminalFailureDetail; /** * The durable pause this failure STANDS IN FOR — set only by the nested hard boundary * (`mapNestedSuspend`): an orchestrator's nested leg paused on a durable gate, the orchestrator cannot * drive a resume from the inside, so at ITS boundary the pause is a failure — but the pause's own * cause travels here verbatim so the top-level caller keeps the recovery capability * (`nestedPause.token` → `runner.resume`). Absent on every failure the engine itself assembles. */ nestedPause?: PausedCause; } | { kind: "blocked"; /** * Why the run ended without doing the work — one of two sources, told apart by {@link detail}. With no * `detail`: the agent's own words from its `ReportBlocked` call. With `detail.kind === * "prompt_blocked_by_hook"`: the engine's sentence about the deployment's `userPromptSubmit` hook, framed * as a reminder for the model-facing lane (the hook's own words, when it gave some, sit inside the * frame). Terminal: the run is over and nobody is being waited on (contrast `paused`, whose token is the * resume capability). */ reason: string; /** * WHO stopped the run, for the one shape a consumer offers its own way out of: `prompt_blocked_by_hook` — * the prompt was NOT submitted (the objective never became a message and the loop never started), so * changing it and sending again is meaningful; `cause` says which of the hook's four endings it was. * PRESENCE: only on that terminal. ABSENT means the agent reported `ReportBlocked` ("I cannot go on"), or an * engine from before this key — branch on `detail?.kind`, never on the prose in `reason`. */ detail?: TerminalBlockedDetail; } | { kind: "paused"; /** * WHICH pause — who/what must resume (e.g. `{kind:"human", reason, toolName}`), so the caller knows * what decision the checkpoint awaits and which resume outcome it takes (a `policy_ask` for an * approval gate; a `dry_run_review` on a `needs_review` gate; a `plan_review` on a `plan_review` * gate). Its kind's {@link import("./pause-registry.js").PAUSE_REGISTRY} row is what * {@link import("./runner/terminal-projection.js").terminalProjection} reads the status word from * (`"suspended"` for an approval / a resource slice / a platform pause, `"needs_review"` for a review). */ gate: import("./checkpoint-store.js").CheckpointGate; /** * The durable-checkpoint token to resume this task with via `runner.resume(token, outcome)`. * Branded `CheckpointToken`; **never log it or put it in a URL** (it is the resume capability, §6). */ token: import("./checkpoint-store.js").CheckpointToken; /** * The pause's NON-SECRET stable identity ({@link import("./checkpoint-store.js").Checkpoint.checkpointId}) * — the display/correlation key a consumer may log or render where the token must not travel. * Absent on pre-identity checkpoints. */ checkpointId?: string; /** * HOW the paused task's remote workspace will come back, present whenever the pause captured a * remote workspace. The two modes bill and behave very differently: * - `"snapshot"` — the workspace VM was `suspendVM`-paused into a snapshot. Provider billing * typically stops; in-memory process state is captured; resume restores it. * - `"park_only"` — the env declared itself non-suspendable (an SSH host / an ADB device), so * NOTHING was paused: the machine keeps running (and keeps costing), any in-memory process state * is at the target's mercy, and resume simply reconnects to the still-present workspace. * Deliberate and honest (core does not fabricate a snapshot) — but a scheduler that assumes * "paused ⇒ idle and free" must be able to see it. * ABSENT for a process-local pause (a static caller-owned env: no remote workspace was captured). * The same discriminant rides {@link import("./checkpoint-store.js").CheckpointSummary} for the * inbox/`listByScope` face. */ restoreMode?: "snapshot" | "park_only"; }; /** The machine-readable account a `completed` cause may carry ({@link TerminalCause}). `kind` is closed; one member. */ export interface TerminalCompletedDetail { kind: "finish_reason_unknown"; /** The provider's stream-end word, in the provider echo shape (`?` when it had bytes outside it). */ finishReasonUnknown: string; } /** * The machine-readable account a `failed` cause may carry ({@link TerminalCause}) — the ONE projection of a fact the * failure's mint point already stated, carried as it was stated (a missing fact stays missing; nothing is re-derived * from the message). `kind` is closed: * - `provider_rejected` — the provider REFUSED the request as sent (a 4xx answer) and that refusal named the result: * the terminal message's refusal account (`apiErrorStatus` / `apiErrorCode` / the provider-condition verdict / the * request's `apiErrorRoute`). * - `coded` — a failure THROWN with a machine code whose throw site stated a structured `detail` beside it (e.g. a * `CheckpointError`'s `{ reason }` on `resume.parent_constraint_mismatch`): that detail, as a JSON-plain snapshot. * `terminal.code` is the same error's code. * - `model_output_invalid` — the model's turn failed because its tool calls could not be executed: the terminal * message's `modelOutputInvalid` account. * New members join with a minor; a consumer that branches on `kind` keeps a default arm. */ export type TerminalFailureDetail = { kind: "provider_rejected"; /** The refusal's HTTP status, 400–499. */ status: number; /** The provider's own word for the refusal (its error envelope's `error.code`, else `error.type`), when stated. */ code?: string; /** * What the refusal says about the account — the engine's closed word for it (`PROVIDER_CONDITIONS`), judged off * the provider dialect table by where the request went: `quota_exhausted` (stop; someone must act or a window * must reset), `throttled` / `overloaded` / `unknown` (a retryable condition the retry budget ran out on, or the * provider's own `x-should-retry: false` stopped), `stop_other` (credentials, model access, the request itself). * The terminal's reading of the same verdict the call's retry frames carried. Present whenever the terminal * message states one (`AssistantMessage.providerCondition`); a host-minted message that did not judge leaves it * absent. */ providerCondition?: ProviderCondition; /** Which evidence {@link providerCondition} rests on. Present exactly when it is. */ providerConditionSource?: ProviderConditionSource; /** The terminal message's `providerResetAtMs`, carried beside a `quota_exhausted` condition. */ providerResetAtMs?: number; /** * The route the refused request took (`AssistantMessage.apiErrorRoute`): where its credential came from, how it * rode, which knob named its root — the knob words of `ROUTE_CREDENTIAL_PAIRING`, never a key value or a URL. * Present whenever the terminal message states one (the first-party brains always do); a host-minted message that * states none leaves it absent. */ route?: RequestRoute; } | { kind: "coded"; /** * The thrown error's own `detail`, as its throw site stated it — a snapshot of its JSON-plain members (strings, * finite numbers, booleans, null, and arrays / plain objects of those, bounded; a member of any other kind is left * out; bytes — an `ArrayBuffer` view, an index-keyed object — are never a record). A member under a credential * name (the env-credential judge's names: `password`, `apiKey`, `token` …) stands as `"[redacted]"`, unread; every * other string leaf passes the secret-tier redaction (credential shapes masked; a path is kept — it rides this * structured seat, never `message`); a member whose key is not JSON-safe (visible ASCII 1–128, nothing the * redaction would mask) is left out. Read off the same link of the error's `cause` chain whose `code` became * `terminal.code`. PRESENCE: only when that link carried a plain-object `detail` with at least one such member — a * code thrown without one (or a higher-ranked terminal that named the result) leaves the whole account absent. */ detail: CodedFailureDetail; } | { kind: "model_output_invalid"; /** Which drop cause failed the turn (`MODEL_OUTPUT_INVALID_CAUSES`: `truncated` — arguments cut before they * parsed; `unnamed` — a call with no tool name). The same words `task.tool_turn_incomplete` announces. */ cause: ModelOutputInvalidCause; /** With `truncated`: the output-cap facts the stream reported (the cap stop, the counted output, the request's * cap) — what the brain's sentence words as its cap clause. */ outputLimit?: OutputLimit; }; /** A JSON-plain value as a `coded` account carries it. */ export type CodedFailureDetailValue = string | number | boolean | null | readonly CodedFailureDetailValue[] | { readonly [key: string]: CodedFailureDetailValue; }; /** The `coded` account's body: the thrown error's `detail` members ({@link TerminalFailureDetail}). */ export type CodedFailureDetail = { readonly [key: string]: CodedFailureDetailValue; }; /** * The endings of a `userPromptSubmit` hook that keep a prompt from being submitted — the closed set of * {@link TerminalBlockedDetail.cause} words: * - `"block"` — the hook answered and refused the prompt (its words ride `hookReason`). * - `"crashed"` — the hook threw or rejected; the seat is fail-closed, a screen that failed has not cleared the prompt. * - `"timeout"` — the hook did not answer within its `timeoutMs` bound; fail-closed the same way. * - `"cancelled"` — the task was cancelled while the hook was still screening. */ export declare const PROMPT_BLOCK_CAUSES: readonly ["block", "crashed", "timeout", "cancelled"]; export type PromptBlockCause = (typeof PROMPT_BLOCK_CAUSES)[number]; /** Membership test for {@link PromptBlockCause}. */ export declare function isPromptBlockCause(v: unknown): v is PromptBlockCause; /** The machine-readable account a `blocked` cause may carry ({@link TerminalCause}). `kind` is closed; one member. */ export interface TerminalBlockedDetail { kind: "prompt_blocked_by_hook"; /** Which of the hook seat's endings kept the prompt back ({@link PROMPT_BLOCK_CAUSES}). */ cause: PromptBlockCause; /** * The hook's own words for refusing the prompt, folded to one line, bounded, with the engine's envelope tags * defused — still the hook's text, so a consumer renders it as untrusted. PRESENT only on cause `"block"` and * only when the hook gave words; never carries the engine's reminder frame or its session mark (the framed * sentence is `reason`). */ hookReason?: string; } /** The paused arm alone — the shape the run's committed-pause holder carries. */ /** * The one disposition table over `TerminalCause["kind"]`: which causes a resume journal may REPLAY * instead of re-running. Only a completed cause is a finished leg; every other cause means the leg has * work left (or failed) and a resume runs it live. The table is also the admission list for a journal * entry's cause at load: a kind absent here (a journal written before `TaskResult.terminal`, a corrupted * or version-skewed entry) is refused before anything is dispatched — it is never read as "not * completed, so run it again", which would replay the side effects of finished work. */ export declare const TERMINAL_CAUSE_IS_REPLAYABLE: { readonly completed: true; readonly failed: false; readonly blocked: false; readonly paused: false; }; /** Compile-time fence: every `TerminalCause` kind has a row in the replay table (and, via `satisfies`, only those). */ export type ReplayTableCoversEveryTerminalCause = AssertAllKeysHandled<Exclude<TerminalCause["kind"], keyof typeof TERMINAL_CAUSE_IS_REPLAYABLE>>; /** The admission read for a journaled cause: a closed-set member, by table row. */ export declare function isTerminalCauseKind(kind: unknown): kind is TerminalCause["kind"]; export type PausedCause = Extract<TerminalCause, { kind: "paused"; }>;