UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

284 lines (283 loc) 14.5 kB
import { TokenUsage } from '../../../types.js'; /** A terminal run status: no further events will be appended. */ export type TerminalRunStatus = 'completed' | 'failed' | 'aborted'; /** * Lifecycle status of one run (one agent turn within a conversation). * * `interrupted` is a human-in-the-loop PAUSE that interrupt-resume continues * from — it is deliberately NOT terminal, and must never be conflated with * `aborted` (an explicit cancellation). * * The two are now written by different hooks and cannot be confused: * * - `'interrupted'` is written ONLY by `withPersistence`'s `onInterrupt`, and * carries NO `finishedAt` (a non-terminal status has not finished). * - `'aborted'` is written by `withPersistence`'s `onAbort`, and only for an * abort that is an explicit cancel or that is ending the run for good. * - A mere client disconnect on a run with durable storage wired writes * NEITHER: the record stays `'running'` and gains `detachedSince`, because the * agent is still running and a later attach can take it over. * * Intent is never inferred from the abort itself — see `RUN_CANCEL_REASON` and * `requestRunCancel` in `../cancel`. */ export type RunStatus = 'running' | 'interrupted' | TerminalRunStatus; /** * Whether `value` is a {@link RunStatus} — the guard a backend validates a row * with at DESERIALIZATION. * * `RunStatus` is a compile-time claim about a storage column. A row arrives as * JSON out of D1, a Durable Object, or Postgres, and nothing in the type system * checked what that column actually held, so a `RunStore` implementation should * run its row's `status` through this before handing the record on. The readers * downstream act DESTRUCTIVELY on the answer — `@tanstack/ai-sandbox`'s journal * sweep DELETES the journal of a run it believes terminal — so a row that lies * about its status is not a display bug. */ export declare function isRunStatus(value: unknown): value is RunStatus; /** * Whether `status` means no further events will be appended. Narrows, so a * caller inside the guard can pass `status` where a {@link TerminalRunStatus} * is required without a cast. * * `Object.hasOwn`, never `in`: `in` walks the prototype chain, so a row whose * `status` column held `'toString'` or `'constructor'` would be reported * terminal. `status` is TYPED `RunStatus`, but every value reaching here comes * off a user-implemented {@link RunStore} and the type is only a claim (see * {@link isRunStatus}). A false `true` deletes a live run's journal * (`@tanstack/ai-sandbox`'s journal sweep), fails its attach as `'terminal-run'` * (`attach-preflight`), and refuses to drive it (`stream-to-response.ts`). */ export declare function isTerminalRunStatus(status: RunStatus): status is TerminalRunStatus; /** * Why a run failed. * * A bare message is an LLM provider's prose: it changes between model * versions and cannot be branched on. `code` is what a consumer switches over * to decide whether to retry, escalate, or surface a specific UI. */ export interface RunError { message: string; /** Stable, machine-branchable classification, when the provider supplies one. */ code?: string; } /** Durable bookkeeping for a single run. */ export interface RunRecord { runId: string; /** * Conversation this run belongs to — the `Scope.threadId`. * * Generation jobs (a one-shot `generate()` with no conversation) must not * reuse this record by faking `threadId = requestId`; they need a separate * job store. `withGenerationPersistence` currently does exactly that and * labels itself a stopgap — do not copy it. */ threadId: string; status: RunStatus; startedAt: number; finishedAt?: number; error?: RunError; usage?: TokenUsage; /** * Compound sandbox key this run was bound to, when it ran in a sandbox. * Recorded so a future reclaimer can identify the sandbox to tear down * without re-deriving the key. Written by `withSandbox`'s detach path * (`onAbort` in `@tanstack/ai-sandbox`'s `middleware.ts`) at the same time as * `detachedSince`, when a disconnect leaves the run detached rather than * destroying the sandbox. A backend must round-trip this field — see * `listReclaimable` below for who eventually reads it. */ sandboxKey?: string; /** * Epoch ms when the last viewer detached; absent while someone is attached. * Written by `withSandbox`'s detach path (`onAbort` in `@tanstack/ai-sandbox`'s * `middleware.ts`) alongside `sandboxKey`, when a disconnect leaves the * agent running rather than tearing the sandbox down. A backend must * round-trip this field: `listReclaimable` depends on it, and * `@tanstack/ai-sandbox`'s `reapDetachedRuns` sweeps the candidates it * surfaces (see that method's doc comment). */ detachedSince?: number; /** * Set by an explicit out-of-band cancel, to be distinguished from a mere * client disconnect (the two produce an identical TCP close, so intent is not * inferable from the disconnect). * * Written by `requestRunCancel` and read by `wasCancelRequested` (both in * `../cancel`). Deliberately NOT a status: recording intent is not the same as * the run having stopped, and only the driver knows when it has. */ cancelRequested?: boolean; /** * Monotonic fencing token for the run's driver. Bumped by each host that * successfully claims the run (see `withRunClaim` in `@tanstack/ai-sandbox`), * so a superseded host can discover it lost by comparing the stored value * against the one it holds. * * A lock alone cannot provide this: it tells the winner it won, but gives a * loser nothing to read. Absent on a run that was never claimed. */ driverEpoch?: number; } /** * Durable store for run lifecycle records. * * REQUIRED: `createOrResume`, `update`, `get`, `findActiveRun`. Every backend * must implement all four — they are what the persistence middleware calls * unconditionally. `findActiveRun` is required rather than feature-detected * because a backend that has not implemented it is indistinguishable from one * whose answer is legitimately `null`, so reconnect would silently do nothing * instead of failing at build time. It was optional for exactly one release * cycle and cost precisely that. * * OPTIONAL: `listByThread`, `listReclaimable`. Each serves one higher-level * feature (thread history, reclaim reaping) and callers feature-detect them, * degrading gracefully when a backend omits them. */ export interface RunStore { /** * Create a run record, or return the existing one unchanged if `runId` is * already present. * * INVARIANT (idempotency): an existing record is returned **unchanged** and * the passed `threadId`/`startedAt`/`status` are ignored. This is what makes * resuming a run safe. `status` defaults to `'running'` on first creation. */ createOrResume: (input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & { status?: RunStatus; }) => Promise<RunRecord>; /** * Patch a record's mutable fields. * * INVARIANT: updating an unknown `runId` is a **no-op** — it must not throw * and must not create a record. */ update: (runId: string, patch: Partial<Pick<RunRecord, 'status' | 'finishedAt' | 'error' | 'usage' | 'sandboxKey' | 'detachedSince' | 'cancelRequested' | 'driverEpoch'>>) => Promise<void>; /** Current record, or null when unknown. */ get: (runId: string) => Promise<RunRecord | null>; /** * Every run in a conversation, ascending by `startedAt`. OPTIONAL: only * needed to render a thread's past agent activity. Consumers feature-detect. */ listByThread?: (threadId: string) => Promise<Array<RunRecord>>; /** * Runs that may be reclaimed: ALL THREE of `status === 'running'`, * `detachedSince` is set, and `detachedSince <= now - ttlMs`. The cutoff is * **inclusive** — a run detached at exactly `now - ttlMs` IS reclaimable. * * OPTIONAL: only needed by a reaper. Consumers feature-detect. * * `detachedSince` is populated by `withSandbox`'s detach path (see * {@link RunRecord.detachedSince}). The sweep over the candidates this * surfaces is `@tanstack/ai-sandbox`'s `reapDetachedRuns`: it finalizes a run * whose agent already finished, expires one past its TTL, and reclaims the * sandbox. That is a function, not a scheduler — the application invokes it * (cron, queue, `alarm()`, `waitUntil`) — and a backend that omits this * method cannot be reaped at all. */ listReclaimable?: (opts: { now: number; ttlMs: number; }) => Promise<Array<RunRecord>>; /** * The most recent `'running'` run for `threadId`, or `null` if none is active. * * REQUIRED. This resolves "does this thread have a live run to attach to?" * from the STABLE thread id, which is the durable basis for reconnecting a * client (a reload, or the same thread opened on another device) — independent * of the ephemeral run id, which a single turn may mint several of. When more * than one run is `'running'`, the one with the greatest `startedAt` wins. * * A backend that stubs this to `null` turns reconnect off silently, because * `null` is also the correct answer for an idle thread. A backend with no run * lifecycle at all should omit the whole `runs` store instead — capability * tiers belong at the store level, not the method level. */ findActiveRun: (threadId: string) => Promise<RunRecord | null>; } /** * Type a {@link RunStore} implementation inline: pass the object and get * autocomplete plus contract checking with no separate annotation. Mirrors * `defineLock` / `defineSandboxInstanceStore`. * * The generic return preserves the argument's own type, so an optional method * the implementation actually provides stays known-present on the result * instead of collapsing back to `| undefined` on the interface. */ export declare function defineRunStore<const T extends RunStore>(store: T): T; /** * Whether the current run can be DETACHED rather than destroyed when its client * disconnects — `true` only when some middleware has both a {@link RunStore} and * a durable event log wired (`withSandbox`'s `runs` + `durability.adapter`). * * Lives in core for the same reason `LockStore` does: it is a coordination fact * that two consumer packages must agree on, and neither may depend on the other. * `@tanstack/ai-sandbox` provides it; `@tanstack/ai-persistence` reads it to * decide whether an abort is terminal (`'aborted'`) or a detach (write nothing). * A persistence → sandbox import would be a layering inversion. * * Consumers read it with `{ optional: true }`: absent means "not detachable", * which is every app that has not wired durability. * * Typed `true`, not `boolean`: ABSENCE is the negative, so a published `false` * has no meaning — and a consumer that tests PRESENCE rather than the value * would read one as "detachable". Narrowing the payload makes that * unrepresentable instead of merely undocumented. */ export declare const DetachableRunCapability: import('./capabilities.js').Capability<true, "detachable-run">; /** * Destructured accessors: `getDetachableRun(ctx, { optional: true })` / * `provideDetachableRun(ctx, true)`. */ export declare const getDetachableRun: import('./capabilities.js').CapabilityGetter<true>, provideDetachableRun: import('./capabilities.js').CapabilityProvider<true>; /** * Whether this run's teardown DID detach — the disconnect was survived, the * agent is still working, and a later attach can take the run over. * * The past-tense counterpart of {@link DetachableRunCapability}, and the two must * not be confused: * * - **detachABLE** is published at `setup`, and only says a disconnect *may* be * survived (a `RunStore` and a durable log are wired). * - **detachED** is published on the ABORT path, by the middleware that actually * makes the call — `withSandbox`'s `onAbort`, which is the only actor that has * resolved BOTH out-of-band cancel bands (`AbortInfo.cancelRequested` and * `wasCancelRequested` on the record) and `detachOnDisconnect`. An explicit * cancel, a non-detachable disconnect, an error, and a normal finish all leave * it unpublished. * * Its consumer is the durable DELIVERY sink in `stream-to-response.ts`: a * detached run's log must stay OPEN and un-terminalized so the takeover can * continue it (see `wasRunDetached` in `../../../delivery-detach`). Reading it * is safe and race-free only because a `for await` over the chat stream awaits * the generator's `return()` — and therefore the whole `onAbort` chain — before * the sink's own `finally` runs. * * Read with `{ optional: true }`: absent means "not detached", which is every * other exit path and every app that has not wired durability. * * Typed `true`, not `boolean`, for the same reason as * {@link DetachableRunCapability}: absence is the only negative, so publishing * `false` must not be representable. */ export declare const RunDetachedCapability: import('./capabilities.js').Capability<true, "run-detached">; /** * Destructured accessors: `getRunDetached(ctx, { optional: true })` / * `provideRunDetached(ctx, true)`. */ export declare const getRunDetached: import('./capabilities.js').CapabilityGetter<true>, provideRunDetached: import('./capabilities.js').CapabilityProvider<true>; /** In-memory {@link RunStore}. Single process only. */ export declare class InMemoryRunStore implements RunStore { private readonly runs; createOrResume(input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & { status?: RunStatus; }): Promise<RunRecord>; update(runId: string, patch: Partial<Pick<RunRecord, 'status' | 'finishedAt' | 'error' | 'usage' | 'sandboxKey' | 'detachedSince' | 'cancelRequested' | 'driverEpoch'>>): Promise<void>; get(runId: string): Promise<RunRecord | null>; listByThread(threadId: string): Promise<Array<RunRecord>>; listReclaimable(opts: { now: number; ttlMs: number; }): Promise<Array<RunRecord>>; findActiveRun(threadId: string): Promise<RunRecord | null>; }