UNPKG

@tanstack/ai

Version:

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

599 lines (565 loc) 23.8 kB
import type { StreamChunk } from './types' /** * A pluggable delivery-durability backend. * * Offsets are owned by the adapter and opaque to the transport. The generic * parameter lets an adapter retain a branded string type across append, read, * and resume without requiring core to understand its cursor format. */ export interface StreamDurability<TOffset extends string = string> { /** Return the adapter offset captured from the request, or null for a producer. */ resumeFrom: () => TOffset | null /** * Persist a batch before it is delivered and return exactly one resumable * offset for each chunk, in the same order. */ append: (chunks: Array<StreamChunk>) => Promise<Array<TOffset>> /** Replay chunks strictly after the supplied adapter-owned offset. */ read: ( offset: TOffset, signal?: AbortSignal, ) => AsyncIterable<{ offset: TOffset; chunk: StreamChunk }> /** * Terminalize the producer log and unblock live readers. Core awaits this * for every producer exit, including completion, cancellation, and failure. */ close: () => Promise<void> /** * Everything stored for this run **at the moment of the call**, in append * order, then resolve. * * This is the bounded counterpart to {@link StreamDurability.read}. `read` * tails: it parks until the log is terminalized or the caller aborts, so it * cannot be used to inspect a log whose producer died without calling * `close` — that log stays open forever and a `for await` over it never * finishes. `snapshot` exists for exactly that case: a producer resuming a * run needs to see the prefix a previous host already stored so it can line * its own output up against it, and it needs that read to *return*. * * Implementations MUST: * * - never wait for more entries — resolve with what is stored, including * while the log is still open and still being appended to; * - resolve to an empty array for a run with nothing stored, rather than * throwing. In particular an implementation must not reuse the * unknown-run failure path a from-start `read` join takes (`read('-1')` on * an empty log is allowed to fail; `snapshot()` is not). A backend over a * network may of course still reject on a transport, protocol, or * authorization failure — that is a failed call, not an empty run; * - return a fresh array the caller can keep or mutate without reaching the * stored log through it. * * The result is a point-in-time view and carries no lock: a concurrent * `append` may land immediately after the snapshot is taken, so a caller * must not treat the last returned offset as the permanent tail. */ snapshot: () => Promise<Array<{ offset: TOffset; chunk: StreamChunk }>> } /** * A {@link StreamDurability} that can re-persist an already-stored range * idempotently. * * A run driver resuming after a crash re-derives the same offsets from its * source position, so replaying an overlapping range must be a no-op rather * than producing duplicates. That capability is deliberately a **separate, * optional method** instead of an optional parameter on `append`: * * - Only adapters that actually support it return this type, so a consumer * requiring the capability asks for `UpsertableStreamDurability` and a * mismatch is a compile error rather than a runtime failure buried in a * run log. * - Pairing each chunk with its offset structurally makes a length mismatch * and an unpaired chunk unrepresentable. A sparse hole is still * representable, so implementations must reject one explicitly. * * Implementations MUST validate the entire batch before mutating any stored * state (so a rejected call never partially applies), MUST reject an offset * they did not mint themselves (every accepted offset is resumable by * definition), MUST reject an offset repeated within one batch, and MUST * reject a hole in the entries array. */ export interface UpsertableStreamDurability< TOffset extends string = string, > extends StreamDurability<TOffset> { /** * Persist a batch at caller-supplied offsets, replacing any entry already * stored at the same offset. Returns the offsets in the order supplied. */ upsert: ( entries: Array<{ chunk: StreamChunk; offset: TOffset }>, ) => Promise<Array<TOffset>> } const MEMORY_OFFSET_PREFIX = 'memory:v1:' interface MemoryOffset { runId: string seq: number } function encodeMemoryOffset(runId: string, seq: number): string { return `${MEMORY_OFFSET_PREFIX}${encodeURIComponent(runId)}:${seq}` } function decodeMemoryOffset(offset: string): MemoryOffset { if (!offset.startsWith(MEMORY_OFFSET_PREFIX)) { throw new Error(`Invalid memory stream offset: ${offset}`) } const encoded = offset.slice(MEMORY_OFFSET_PREFIX.length) const separator = encoded.lastIndexOf(':') if (separator === -1) { throw new Error(`Invalid memory stream offset: ${offset}`) } const runId = decodeURIComponent(encoded.slice(0, separator)) const seq = Number(encoded.slice(separator + 1)) if (!Number.isSafeInteger(seq) || seq < 1) { throw new Error(`Invalid memory stream offset: ${offset}`) } return { runId, seq } } function readResumeOffset(request: Request): string | null { const header = request.headers.get('Last-Event-ID') if (header) return header try { return new URL(request.url).searchParams.get('offset') } catch { return null } } /** * The run id a request names: `X-Run-Id` header first, then `?runId`. * * The single implementation of that precedence, shared by the durability * adapters below and by the resume response helpers' run driver * (`stream-to-response.ts`), so the helper and the adapter can never disagree * about which run a request is talking about. */ export function resolveResumeRunId(request: Request): string | null { // A POST producer carries its client-chosen run id in the X-Run-Id header so // the request URL stays byte-identical to a plain, non-durable request; the // GET join path carries it in the ?runId query instead. Prefer the header, // fall back to the query. const header = request.headers.get('X-Run-Id') if (header) return header try { return new URL(request.url).searchParams.get('runId') } catch { return null } } function assertValidRunId(runId: string): string { if (runId.length === 0 || /[\r\n]/.test(runId)) { throw new Error( `Invalid runId (must be non-empty and contain no CR/LF): ${JSON.stringify(runId)}`, ) } return runId } function resolveMemoryRunId( request: Request, resumeOffset: string | null, ): string { if ( resumeOffset !== null && resumeOffset !== '-1' && resumeOffset !== 'now' ) { return assertValidRunId(decodeMemoryOffset(resumeOffset).runId) } const requestedRunId = resolveResumeRunId(request) return requestedRunId === null ? crypto.randomUUID() : assertValidRunId(requestedRunId) } function memoryThreshold(offset: string, runId: string, tail: number): number { if (offset === '-1') return -1 if (offset === 'now') return tail const decoded = decodeMemoryOffset(offset) if (decoded.runId !== runId) { throw new Error( `Memory stream offset belongs to run ${JSON.stringify(decoded.runId)}, not ${JSON.stringify(runId)}`, ) } return decoded.seq } interface MemoryEntry { seq: number offset: string chunk: StreamChunk } /** * One validated action from an `upsert` batch. Building the whole plan before * applying any of it is what keeps a rejected `upsert` from partially mutating * the log. */ type UpsertStep = | { kind: 'replace'; existing: MemoryEntry; chunk: StreamChunk } | { kind: 'push'; seq: number; offset: string; chunk: StreamChunk } interface MemoryLog { entries: Array<MemoryEntry> complete: boolean /** Epoch ms when the log was terminalized; undefined while still producing. */ completedAt: number | undefined waiters: Array<() => void> } /** * Bounds for the in-process log store. `memoryStream` is the dev/single-process * backend; without eviction its module-global Map would grow without bound on a * long-lived server (one retained chunk buffer per run, forever). Completed logs * are swept after a grace window — late resumers/joiners still work briefly — * and a hard cap drops the oldest completed logs under pressure. Active * (incomplete) logs are never evicted, so an in-flight run is never dropped. */ const MAX_MEMORY_RUNS = 1024 const COMPLETED_LOG_TTL_MS = 5 * 60_000 /** * How long a from-start join (`-1` / `now`) waits for a run's first chunk before * failing. Bounds the "joined a run that never produces" case so a consumer * gets a surfaced error instead of an indefinitely-open, event-less connection. * * Defaults short: the common from-start join is a reload rejoining a run whose * producer ran in a PRIOR request, so an in-flight run's log already holds * chunks (it streams immediately, deadline never applies) and an empty log means * the run is gone — failing fast lets the client re-enable input near-instantly * instead of hanging. Raise `firstChunkDeadlineMs` for backends where a producer * legitimately starts well after a joiner attaches (a queued/deferred job). */ const DEFAULT_FIRST_CHUNK_DEADLINE_MS = 100 /** Options for the in-process delivery-durability backend. */ export interface MemoryStreamOptions { /** * Milliseconds a from-start join waits for the run's first chunk before * throwing. Defaults to {@link DEFAULT_FIRST_CHUNK_DEADLINE_MS} (100ms) — * raise it if a producer can legitimately start long after a joiner attaches. */ firstChunkDeadlineMs?: number } const memoryLogs = new Map<string, MemoryLog>() /** * Evict completed logs past their grace window, then, if still over the cap, * drop the oldest completed logs (the Map preserves insertion order) until back * under the cap. Never touches an incomplete (in-flight) log. */ function sweepMemoryLogs(now: number): void { for (const [id, log] of memoryLogs) { if ( log.complete && log.completedAt !== undefined && now - log.completedAt > COMPLETED_LOG_TTL_MS ) { memoryLogs.delete(id) } } if (memoryLogs.size <= MAX_MEMORY_RUNS) return for (const [id, log] of memoryLogs) { if (memoryLogs.size <= MAX_MEMORY_RUNS) break if (log.complete) memoryLogs.delete(id) } } function getOrCreateLog(id: string): MemoryLog { let log = memoryLogs.get(id) if (!log) { sweepMemoryLogs(Date.now()) log = { entries: [], complete: false, completedAt: undefined, waiters: [] } memoryLogs.set(id, log) } return log } function markComplete(log: MemoryLog): void { if (!log.complete) { log.complete = true log.completedAt = Date.now() } } function wakeWaiters(log: MemoryLog): void { const waiters = log.waiters log.waiters = [] for (const wake of waiters) wake() } /** * Explicit construction for {@link memoryStream}, for callers that don't have * the incoming `Request` — e.g. a TanStack Start server function implementing * a `joinRun` replay for a run id it received as call data: * * ```ts * const durability = memoryStream({ runId }) * for await (const chunk of replayRunStream(durability)) yield chunk * ``` */ export interface MemoryStreamInit { /** The run this durability adapter attaches to. */ runId: string /** * Resume offset captured by the consumer (`resumeFrom()` returns it). * Defaults to `null` (a producer / from-start reader). */ offset?: string | null } /** * The zero-infrastructure delivery-durability backend. Its versioned cursor is * deliberately private: callers and core only pass the returned string back. * * Construct from the incoming `Request` (HTTP transports) or from an explicit * {@link MemoryStreamInit} (server functions / direct calls that already know * the run id). * * Logs live in a process-global map, so this backend is for development, tests, * and single-process deployments only. Completed runs are evicted after a grace * window (see {@link COMPLETED_LOG_TTL_MS}); a resume of an evicted or unknown * run fails loudly rather than hanging. */ export function memoryStream( source: Request | MemoryStreamInit, options: MemoryStreamOptions = {}, ): UpsertableStreamDurability { const resumeOffset = source instanceof Request ? readResumeOffset(source) : (source.offset ?? null) const runId = source instanceof Request ? resolveMemoryRunId(source, resumeOffset) : assertValidRunId(source.runId) const firstChunkDeadlineMs = options.firstChunkDeadlineMs ?? DEFAULT_FIRST_CHUNK_DEADLINE_MS return { resumeFrom: () => resumeOffset, // `async` so every failure surfaces as a rejected promise rather than a // synchronous throw at the call site — `append` is declared to return a // Promise, so callers must be able to `.catch()` every failure mode. append: async (chunks) => { const log = getOrCreateLog(runId) const firstSeq = (log.entries.at(-1)?.seq ?? 0) + 1 const offsets = chunks.map((chunk, index) => { const seq = firstSeq + index const offset = encodeMemoryOffset(runId, seq) log.entries.push({ seq, offset, chunk }) return offset }) wakeWaiters(log) return offsets }, // `async` for the same reason as `append`: every validation failure below // must be observable via `.catch()`, never as a synchronous throw. upsert: async (entries) => { const log = getOrCreateLog(runId) const tailSeq = log.entries.at(-1)?.seq ?? 0 // Validate the WHOLE batch before touching `log.entries`, so a rejected // upsert never partially applies and a caller that catches and retries // can be sure no prefix landed. const seen = new Set<string>() // Tail as it will stand once every push planned so far has been applied, // so intra-batch ordering is validated up front too. let plannedTailSeq = tailSeq // `Array.from` rather than `entries.map`: `map` SKIPS holes in a sparse // array, which would leave the plan short and make the apply loop below // read `undefined` partway through, after earlier steps had already // mutated the log. `Array.from` invokes this callback for every index, // so a hole is rejected here, before anything is touched. const plan = Array.from(entries, (entry, index): UpsertStep => { if (entry === undefined) { throw new Error( `memoryStream: entries[${index}] is missing; entries must be dense`, ) } const { chunk, offset } = entry let decoded: MemoryOffset try { decoded = decodeMemoryOffset(offset) } catch (cause) { throw new Error( `memoryStream: entries[${index}].offset ${JSON.stringify(offset)} is not a resumable memory stream offset: ${cause instanceof Error ? cause.message : String(cause)}`, ) } if (decoded.runId !== runId) { throw new Error( `memoryStream: entries[${index}].offset ${JSON.stringify(offset)} belongs to run ${JSON.stringify(decoded.runId)}, not ${JSON.stringify(runId)}`, ) } const seq = decoded.seq if (seen.has(offset)) { throw new Error( `memoryStream: entries[${index}].offset ${JSON.stringify(offset)} is repeated within the batch; each offset may appear at most once`, ) } seen.add(offset) const existing = log.entries.find((stored) => stored.offset === offset) if (existing) return { kind: 'replace', existing, chunk } // A not-yet-stored offset must sit strictly after the current tail. // `read()` walks `entries` in array order and filters `seq > threshold`, // so a pushed entry has to keep the seqs monotonically increasing; // reusing the offset's own decoded seq also keeps a returned offset's // threshold exactly consistent with the entry it names. Gaps are fine — // nothing depends on seqs being contiguous, only on them increasing — // so do NOT "fix" this to renumber densely. if (seq <= plannedTailSeq) { throw new Error( `memoryStream: entries[${index}].offset ${JSON.stringify(offset)} is not stored yet but claims position ${seq}, at or before the tail ${plannedTailSeq}; a new offset must come after every stored and preceding entry`, ) } plannedTailSeq = seq return { kind: 'push', seq, offset, chunk } }) // Validation passed for every entry — mutation below cannot fail. for (const step of plan) { if (step.kind === 'replace') { step.existing.chunk = step.chunk } else { log.entries.push({ seq: step.seq, offset: step.offset, chunk: step.chunk, }) } } wakeWaiters(log) return plan.map((step) => step.kind === 'replace' ? step.existing.offset : step.offset, ) }, snapshot: () => { // Peek, never getOrCreateLog: an unknown run must resolve to `[]`, and // inserting an empty, never-completed log here would leave a permanent // entry the sweep cannot reclaim (it only reclaims complete logs). const log = memoryLogs.get(runId) if (log === undefined) return Promise.resolve([]) // Fresh outer array AND fresh pair objects, so a caller that mutates the // result cannot reach `log.entries` or the stored entries through it. // Never touches `log.waiters` — a snapshot is a point-in-time read and // returns even while the log is open and still being appended to. return Promise.resolve( log.entries.map((entry) => ({ offset: entry.offset, chunk: entry.chunk, })), ) }, close: () => { const log = getOrCreateLog(runId) markComplete(log) wakeWaiters(log) return Promise.resolve() }, read: async function* (offset, signal) { const isFromStartJoin = offset === '-1' || offset === 'now' // Peek, never getOrCreateLog. A concrete resume offset for an absent run // means the run was evicted (or never lived in this process) and will not // reappear — fail WITHOUT inserting a log. Inserting here would leave a // permanent empty, never-completed log (sweep only reclaims complete // ones), so client-supplied offsets could grow the map without bound and // defeat the eviction this backend relies on. let log = memoryLogs.get(runId) if (log === undefined || (log.entries.length === 0 && !log.complete)) { if (!isFromStartJoin) { throw new Error( `Unknown or expired memory stream run: ${JSON.stringify(runId)}`, ) } // A from-start join may legitimately attach before the producer creates // the log (second-tab race); create it so a later append reuses the // same entry. If no producer ever arrives, the first-chunk deadline // below deletes this phantom before rejecting. log = getOrCreateLog(runId) } const threshold = memoryThreshold( offset, runId, log.entries.at(-1)?.seq ?? 0, ) let index = 0 for (;;) { while (index < log.entries.length) { const entry = log.entries[index] index += 1 if (entry && entry.seq > threshold) { yield { offset: entry.offset, chunk: entry.chunk } } } // A terminal chunk (RUN_FINISHED / RUN_ERROR) does NOT end the read: an // agent-loop run emits one per iteration (finishReason "tool_calls" then // "stop"), so stopping on the first would truncate a tool-calling run at // its first tool call. The producer signals true completion by calling // `close()` (it does so on every exit — see StreamDurability.close), which // sets `log.complete`. Read tails until then, or until the caller aborts. if (log.complete || signal?.aborted) return // Bound only the wait for the very first chunk: once a run has produced // anything, its producer owns termination and a caught-up reader may // legitimately park indefinitely between chunks. const deadlineForFirstChunk = log.entries.length === 0 ? firstChunkDeadlineMs : undefined await new Promise<void>((resolve, reject) => { let timer: ReturnType<typeof setTimeout> | undefined const cleanup = () => { if (timer !== undefined) clearTimeout(timer) signal?.removeEventListener('abort', onAbort) const waiterIndex = log.waiters.indexOf(wake) if (waiterIndex !== -1) log.waiters.splice(waiterIndex, 1) } const onAbort = () => { cleanup() resolve() } const wake = () => { cleanup() resolve() } log.waiters.push(wake) signal?.addEventListener('abort', onAbort, { once: true }) if (deadlineForFirstChunk !== undefined) { timer = setTimeout(() => { cleanup() // No producer ever created data for this joined run. Drop the // phantom log we created above so it does not linger uncollected // (it is empty and will never be marked complete). if ( log.entries.length === 0 && !log.complete && memoryLogs.get(runId) === log ) { memoryLogs.delete(runId) } reject( new Error( `Memory stream run produced no data within ${deadlineForFirstChunk}ms: ${JSON.stringify(runId)}`, ), ) }, deadlineForFirstChunk) } }) } }, } } /** * Replay a run's delivery-durability log as a bare stream of chunks, for * callers that serve a `joinRun` handler without an HTTP `Response` — e.g. a * TanStack Start server function returning an async iterable: * * ```ts * async function* joinImageRun({ data: runId }: { data: string }) { * yield* replayRunStream(memoryStream({ runId })) * } * * // Serve it from a server function whose handler is the generator above * // (`createServerFn({ method: 'GET' }).inputValidator(...)`). * ``` * * NOTE: the example deliberately declares the generator separately instead of * inlining it into the server-fn builder chain. TanStack Start's server-fn * Vite plugin decides whether a module needs compiling by regex-matching the * SOURCE for a dotted `handler(` call, and JSDoc survives into `dist` — an * inlined chain here would make every Start app treat this package as a * server-fn module and try to resolve its framework's `@tanstack/*-start` * package, failing the build wherever that framework is not the one installed. * * Reads from `offset` (default `'-1'` — from the start) and tails until the * producer closes the log or `signal` aborts, exactly like the HTTP * `resumeServerSentEventsResponse` path. */ export async function* replayRunStream<TOffset extends string>( durability: StreamDurability<TOffset>, offset?: TOffset, signal?: AbortSignal, ): AsyncGenerator<StreamChunk> { // '-1' is the from-start replay sentinel every shipped backend honors. const from = offset ?? ('-1' as TOffset) for await (const { chunk } of durability.read(from, signal)) { yield chunk } }