@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
1,205 lines (1,204 loc) • 47.1 kB
JavaScript
import { validateChatPersistenceStores, validateGenerationPersistenceStores } from "./types.js";
import { InterruptsCapability, PersistenceCapability, PersistenceCompletionCapability, provideInterrupts, providePersistence, providePersistenceCompletion } from "./capabilities.js";
import { artifactBlobKey } from "./retrieve.js";
import { InterruptResumeValidationError, MetadataCapability, defineChatMiddleware, fromSpecTokenUsage, getDetachableRun, provideMetadata, readInterruptBinding, validateInterruptResumeBatch, wasCancelRequested } from "@tanstack/ai";
import { createInterruptBinding, getGenericInterruptDefinitionRegistry, providePendingTurn, rehydrateInterruptRequest, toRunErrorPayload } from "@tanstack/ai/adapter-internals";
import { base64ToUint8Array } from "@tanstack/ai-utils";
//#region src/middleware.ts
/**
* The slot this generation's runs are filed under: `ctx.threadId` (the
* `threadId` the caller passed the activity), or the option when it overrides.
*
* Throws when neither supplies one. A run filed under no scope can never be
* hydrated by one, so `persistence: true` would restore nothing, forever. That
* is worth failing loudly for, since the alternative is a silent hole a reader
* cannot diagnose from behavior.
*/
function generationScope(ctx, opts) {
const threadId = opts.threadId ?? ctx.threadId;
if (threadId === void 0 || threadId.length === 0) throw new Error("Generation persistence requires a `threadId`, the stable scope successive runs are filed under. Pass it to the activity, e.g. `generateImage({ threadId, middleware: [withGenerationPersistence(p)] })`, or override it with `withGenerationPersistence(p, { threadId })`.");
return threadId;
}
var DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS = 3e4;
var DEFAULT_MAX_ARTIFACT_BYTES = 1073741824;
var runState = /* @__PURE__ */ new WeakMap();
var validResumeStatuses = /* @__PURE__ */ new Set(["resolved", "cancelled"]);
function mergeMaps(left, right) {
if (!left && !right) return void 0;
return new Map([...left ?? [], ...right ?? []]);
}
function mergeSets(left, right) {
if (!left && !right) return void 0;
return /* @__PURE__ */ new Set([...left ?? [], ...right ?? []]);
}
function mergeResumeToolState(left, right) {
if (!left) return right;
if (!right) return left;
return {
approvals: mergeMaps(left.approvals, right.approvals),
clientToolResults: mergeMaps(left.clientToolResults, right.clientToolResults),
genericInterrupts: mergeMaps(left.genericInterrupts, right.genericInterrupts),
genericInterruptRequests: mergeMaps(left.genericInterruptRequests, right.genericInterruptRequests),
deniedToolResults: mergeMaps(left.deniedToolResults, right.deniedToolResults),
cancelledToolCallIds: mergeSets(left.cancelledToolCallIds, right.cancelledToolCallIds)
};
}
function rejectMixedRunPending(pending, ctx) {
if (new Set(pending.map((interrupt) => interrupt.runId)).size <= 1) return;
throw new InterruptResumeValidationError([{
scope: "batch",
threadId: ctx.threadId,
interruptedRunId: ctx.runId,
generation: 0,
interruptIds: pending.map((interrupt) => interrupt.interruptId),
code: "stale",
message: "Thread has pending interrupts from more than one run.",
source: "server",
retryable: false
}]);
}
function validatePendingResumes(pending, resume, ctx) {
const interruptedRunId = pending[0]?.runId ?? ctx.runId;
const failure = (interruptId, code, message) => {
throw new InterruptResumeValidationError([{
scope: "item",
threadId: ctx.threadId,
interruptedRunId,
generation: 0,
interruptId,
code,
message,
source: "client",
retryable: false
}, {
scope: "batch",
threadId: ctx.threadId,
interruptedRunId,
generation: 0,
interruptIds: pending.map((interrupt) => interrupt.interruptId),
code: code === "conflict" ? "conflict" : "incomplete-batch",
message: "Resume entries must resolve or cancel the complete interrupt batch.",
source: "client",
retryable: false
}]);
};
const pendingInterruptIds = new Set(pending.map((interrupt) => interrupt.interruptId));
const resumeByInterruptId = /* @__PURE__ */ new Map();
for (const entry of resume ?? []) {
if (resumeByInterruptId.has(entry.interruptId)) return failure(entry.interruptId, "conflict", `Interrupt ${entry.interruptId} has duplicate resume entries.`);
resumeByInterruptId.set(entry.interruptId, entry);
}
if (pending.length === 0) {
const staleEntry = resume?.[0];
if (staleEntry) return failure(staleEntry.interruptId, "unknown-interrupt", `Resume entry references non-pending interrupt ${staleEntry.interruptId}.`);
return resumeByInterruptId;
}
const firstPending = pending[0];
if (firstPending === void 0) return resumeByInterruptId;
if (!resume || resume.length === 0) return failure(firstPending.interruptId, "unknown-interrupt", `Thread has pending interrupts; resume is required before accepting new input.`);
for (const interrupt of pending) {
const entry = resumeByInterruptId.get(interrupt.interruptId);
if (!entry) return failure(interrupt.interruptId, "unknown-interrupt", `Missing resume entry for pending interrupt ${interrupt.interruptId}.`);
if (!validResumeStatuses.has(entry.status)) return failure(interrupt.interruptId, "unknown-interrupt", `Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`);
}
for (const entry of resume) if (!pendingInterruptIds.has(entry.interruptId)) return failure(entry.interruptId, "unknown-interrupt", `Resume entry references non-pending interrupt ${entry.interruptId}.`);
return resumeByInterruptId;
}
async function applyPendingResumes(pending, resumeByInterruptId, interrupts) {
const entries = [];
for (const interrupt of pending) {
const entry = resumeByInterruptId.get(interrupt.interruptId);
if (!entry) continue;
if (entry.status === "resolved") entries.push({
interruptId: interrupt.interruptId,
status: "resolved",
response: entry.payload
});
else entries.push({
interruptId: interrupt.interruptId,
status: "cancelled"
});
}
if (interrupts.commitBatch) {
await interrupts.commitBatch(entries);
return;
}
const ids = /* @__PURE__ */ new Set();
for (const entry of entries) {
if (ids.has(entry.interruptId)) throw new Error(`Interrupt batch contains duplicate id: ${entry.interruptId}.`);
ids.add(entry.interruptId);
const existing = await interrupts.get(entry.interruptId);
if (!existing) throw new Error(`Interrupt batch references missing id: ${entry.interruptId}.`);
if (existing.status !== "pending") throw new Error(`Interrupt batch references non-pending id: ${entry.interruptId}.`);
}
for (const entry of entries) if (entry.status === "resolved") await interrupts.resolve(entry.interruptId, entry.response);
else await interrupts.cancel(entry.interruptId);
}
/**
* Commit the resumes stashed in `onConfig`, marking each resumed interrupt
* resolved/cancelled. Called only from success boundaries (`onFinish`, and the
* `onChunk` interrupt boundary) so a provider failure or abort between accepting
* the resume and reaching a boundary leaves the interrupts pending — the
* approval is not consumed and a retry with the same resume succeeds. Idempotent
* and a no-op when nothing is stashed.
*/
async function commitPendingResumes(state, interrupts) {
if (!state?.pendingResumes || !interrupts) return;
const { pending, resumeByInterruptId } = state.pendingResumes;
await applyPendingResumes(pending, resumeByInterruptId, interrupts);
state.pendingResumes = void 0;
}
function objectValue(value) {
return value && typeof value === "object" ? value : null;
}
function stringField(value, key) {
return typeof value[key] === "string" ? value[key] : void 0;
}
function interruptKind(interrupt) {
const metadata = objectValue(interrupt.payload.metadata);
return metadata ? stringField(metadata, "kind") : void 0;
}
function hasReservedInterruptBinding(payload) {
const metadata = objectValue(objectValue(payload)?.metadata);
return !!metadata && "tanstack:interruptBinding" in metadata;
}
function isPersistedInterruptDescriptor(value) {
const record = objectValue(value);
return !!record && typeof record.id === "string" && typeof record.reason === "string" && typeof record.message === "string";
}
/**
* Does this pending record belong to the TanStack chat resume protocol?
*
* An external system can persist an AG-UI descriptor in the same durable
* thread. A descriptor without a TanStack binding or legacy tool marker stays
* pending for its owner, but it does not make this resume incomplete. Older
* opaque records remain owned because their provenance cannot be known.
*/
function isChatOwnedPendingInterrupt(interrupt) {
const kind = interruptKind(interrupt);
return !isPersistedInterruptDescriptor(interrupt.payload) || stringField(interrupt.payload, "toolCallId") !== void 0 || kind === "approval" || kind === "client_tool" || hasReservedInterruptBinding(interrupt.payload);
}
function durableGenericFailure(ctx, persisted, message) {
return new InterruptResumeValidationError([{
scope: "item",
threadId: ctx.threadId,
interruptedRunId: persisted.runId || ctx.runId,
generation: 0,
interruptId: persisted.interruptId,
code: "stale",
message,
source: "server",
retryable: false
}, {
scope: "batch",
threadId: ctx.threadId,
interruptedRunId: persisted.runId || ctx.runId,
generation: 0,
interruptIds: [persisted.interruptId],
code: "item-validation-failed",
message: "One or more persisted interrupt records are invalid.",
source: "server",
retryable: false
}]);
}
async function durableGenericResumeState(ctx, pending, resume, tools) {
const registry = getGenericInterruptDefinitionRegistry(ctx, { optional: true });
const records = [];
for (const persisted of pending) {
if (!isPersistedInterruptDescriptor(persisted.payload)) {
if (hasReservedInterruptBinding(persisted.payload)) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has an invalid binding descriptor.`);
continue;
}
const descriptor = persisted.payload;
const binding = readInterruptBinding(descriptor);
if (!binding) {
if (hasReservedInterruptBinding(descriptor)) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has an invalid or incomplete binding.`);
continue;
}
if (descriptor.id !== persisted.interruptId || binding.interruptId !== persisted.interruptId || binding.interruptedRunId !== persisted.runId || binding.generation !== 0) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has stale correlation metadata.`);
if (binding.kind !== "generic") {
records.push({
interruptId: persisted.interruptId,
payload: descriptor,
binding
});
continue;
}
if (!binding.definitionId || !binding.key || binding.batchIndex === void 0) {
records.push({
interruptId: persisted.interruptId,
payload: descriptor,
binding
});
continue;
}
if (!registry) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} cannot be restored because no interrupt registry is available.`);
const definition = registry.definitions.get(binding.definitionId);
if (!definition) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt definition ${binding.definitionId} is unavailable.`);
const payload = objectValue(descriptor.metadata)?.["tanstack:interruptPayload"];
let request;
try {
request = rehydrateInterruptRequest(definition, {
key: binding.key,
reason: descriptor.reason,
message: descriptor.message,
...descriptor.expiresAt !== void 0 ? { expiresAt: descriptor.expiresAt } : {},
...payload !== void 0 ? { payload } : {}
});
} catch (error) {
throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} is invalid: ${error instanceof Error ? error.message : String(error)}`);
}
const emitted = createInterruptBinding(request, { batchIndex: binding.batchIndex });
if (emitted.descriptor.responseSchemaHash !== binding.responseSchemaHash || emitted.descriptor.payloadSchemaHash !== binding.payloadSchemaHash || binding.interruptId !== persisted.interruptId) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} is stale.`);
records.push({
interruptId: persisted.interruptId,
payload: descriptor,
binding,
genericRequest: request
});
}
const firstRecord = records[0];
if (firstRecord === void 0) return void 0;
const interruptedRunId = firstRecord.binding.interruptedRunId;
const generation = firstRecord.binding.generation;
const validated = await validateInterruptResumeBatch({
threadId: ctx.threadId,
interruptedRunId,
generation,
pending: records,
resume: resume.filter((entry) => records.some((record) => record.interruptId === entry.interruptId)),
tools
});
if (validated.errors.length > 0 || !validated.resumeToolState) throw new InterruptResumeValidationError(validated.errors);
const isGenericRecord = (record) => record.binding.kind === "generic" && record.genericRequest !== void 0;
const genericRecords = [];
const batchIndexes = /* @__PURE__ */ new Set();
for (const record of records) {
if (!isGenericRecord(record)) continue;
const batchIndex = record.binding.batchIndex;
if (batchIndex === void 0 || batchIndexes.has(batchIndex)) throw new InterruptResumeValidationError([{
scope: "batch",
threadId: ctx.threadId,
interruptedRunId,
generation,
interruptIds: records.map((item) => item.interruptId),
code: "stale",
message: "Persisted generic interrupts have duplicate or invalid batch indexes.",
source: "server",
retryable: false
}]);
batchIndexes.add(batchIndex);
genericRecords.push({
record,
batchIndex
});
}
genericRecords.sort((left, right) => left.batchIndex - right.batchIndex);
return {
...validated.resumeToolState,
genericInterruptRequests: new Map(genericRecords.flatMap(({ record }) => record.genericRequest ? [[record.interruptId, record.genericRequest]] : []))
};
}
function resolvedApprovalDecision(entry) {
if (entry.status === "cancelled") return false;
const payload = objectValue(entry.payload);
return typeof payload?.approved === "boolean" ? payload.approved : false;
}
/**
* Translate the persisted pending interrupts + the resume batch into the
* `ChatResumeToolState` the chat engine consumes. This is the server-authoritative
* counterpart to the engine's ephemeral (client-history) reconstruction: because
* the persistence flow sends empty client messages, the engine has no history to
* rebuild from, so persistence supplies the resume state directly (and clears
* `config.resume` so the ephemeral path is skipped — see `onConfig`).
*/
function resumeToolStateFromPending(pending, resumeByInterruptId) {
const approvals = /* @__PURE__ */ new Map();
const clientToolResults = /* @__PURE__ */ new Map();
const cancelledToolCallIds = /* @__PURE__ */ new Set();
for (const interrupt of pending) {
const entry = resumeByInterruptId.get(interrupt.interruptId);
if (!entry) continue;
const kind = interruptKind(interrupt);
const reason = stringField(interrupt.payload, "reason");
const toolCallId = stringField(interrupt.payload, "toolCallId");
if (entry.status === "cancelled" && toolCallId) cancelledToolCallIds.add(toolCallId);
if (kind === "approval" || reason === "approval_required") {
approvals.set(interrupt.interruptId, resolvedApprovalDecision(entry));
continue;
}
if (entry.status === "resolved" && toolCallId && (kind === "client_tool" || reason === "client_tool_input")) clientToolResults.set(toolCallId, entry.payload);
}
if (approvals.size === 0 && clientToolResults.size === 0 && cancelledToolCallIds.size === 0) return;
return {
approvals,
clientToolResults,
cancelledToolCallIds
};
}
function interruptPayload(interrupt) {
return interrupt && typeof interrupt === "object" ? { ...interrupt } : { value: interrupt };
}
function isArtifactRef(value) {
const record = objectValue(value);
return !!record && typeof record.artifactId === "string";
}
function mediaActivity(activity) {
return activity === "image" || activity === "audio" || activity === "tts" || activity === "video" || activity === "transcription" ? activity : void 0;
}
function parseDataUrl(value) {
const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(value);
if (!match) return void 0;
const mimeType = match[1] || "application/octet-stream";
const raw = match[3] ?? "";
let payload;
try {
payload = decodeURIComponent(raw);
} catch {
payload = raw;
}
return {
mimeType,
bytes: match[2] ? base64ToUint8Array(payload) : new TextEncoder().encode(payload)
};
}
function extensionForMime(mimeType) {
if (mimeType === void 0) return "bin";
switch (mimeType) {
case "image/png": return "png";
case "image/jpeg": return "jpg";
case "audio/wav": return "wav";
case "audio/mpeg": return "mp3";
case "audio/mp3": return "mp3";
case "video/mp4": return "mp4";
case "application/json": return "json";
default: return "bin";
}
}
function defaultArtifactName(descriptor, activity, index) {
const ext = extensionForMime(descriptor.mimeType);
return `${activity}-${descriptor.role}-${descriptor.mediaType ?? "artifact"}-${index}.${ext}`;
}
function sourcePartDescriptors(part, role, path) {
const record = objectValue(part);
const type = stringField(record ?? {}, "type");
const source = objectValue(record?.source);
if (!record || !source || type !== "image" && type !== "audio" && type !== "video") return [];
const sourceType = stringField(source, "type");
const mimeType = stringField(source, "mimeType") ?? `${type}/mpeg`;
if (sourceType === "data") {
const value = stringField(source, "value");
if (!value) return [];
return [{
role,
path,
mediaType: type,
mimeType,
bytes: base64ToUint8Array(value)
}];
}
if (sourceType === "url") {
const value = stringField(source, "value");
if (!value) return [];
return [{
role,
path,
mediaType: type,
mimeType,
url: value
}];
}
return [];
}
function promptInputDescriptors(inputs) {
const prompt = objectValue(inputs)?.prompt;
if (!Array.isArray(prompt)) return [];
const counts = {
image: 0,
audio: 0,
video: 0
};
const descriptors = [];
for (const part of prompt) {
const type = stringField(objectValue(part) ?? {}, "type");
if (type !== "image" && type !== "audio" && type !== "video") continue;
const index = counts[type] ?? 0;
counts[type] = index + 1;
descriptors.push(...sourcePartDescriptors(part, "input", `prompt.${type}s.${index}`));
}
return descriptors;
}
function generatedMediaDescriptor(args) {
const media = objectValue(args.media);
if (!media) return void 0;
const b64Json = stringField(media, "b64Json");
if (b64Json) return {
role: args.role,
path: args.path,
mediaType: args.mediaType,
mimeType: stringField(media, "contentType") ?? args.mimeType,
bytes: base64ToUint8Array(b64Json),
jobId: args.jobId,
expiresAt: args.expiresAt
};
const url = stringField(media, "url");
if (url) return {
role: args.role,
path: args.path,
mediaType: args.mediaType,
mimeType: stringField(media, "contentType") ?? args.mimeType,
url,
jobId: args.jobId,
expiresAt: args.expiresAt
};
}
function builtInArtifactDescriptors(activity, inputs, result) {
const descriptors = promptInputDescriptors(inputs);
const output = objectValue(result);
if (!output) return descriptors;
if (activity === "image" && Array.isArray(output.images)) output.images.forEach((image, index) => {
const descriptor = generatedMediaDescriptor({
role: "output",
path: `images.${index}`,
mediaType: "image",
mimeType: "image/png",
media: image
});
if (descriptor) descriptors.push(descriptor);
});
if (activity === "audio") {
const descriptor = generatedMediaDescriptor({
role: "output",
path: "audio",
mediaType: "audio",
mimeType: "audio/mpeg",
media: output.audio
});
if (descriptor) descriptors.push(descriptor);
}
if (activity === "tts") {
const audio = stringField(output, "audio");
if (audio) {
const format = stringField(output, "format");
descriptors.push({
role: "output",
path: "audio",
mediaType: "audio",
mimeType: stringField(output, "contentType") ?? (format ? `audio/${format}` : "audio/mpeg"),
bytes: base64ToUint8Array(audio)
});
}
}
if (activity === "video" && typeof output.url === "string") descriptors.push({
role: "output",
path: "video",
mediaType: "video",
mimeType: "video/mp4",
url: output.url,
jobId: stringField(output, "jobId"),
expiresAt: output.expiresAt instanceof Date ? output.expiresAt : void 0
});
if (activity === "transcription") {
const audio = objectValue(inputs)?.audio;
if (typeof audio === "string") {
const data = parseDataUrl(audio);
descriptors.push({
role: "input",
path: "audio",
mediaType: "audio",
mimeType: data?.mimeType ?? "audio/mpeg",
bytes: data?.bytes ?? base64ToUint8Array(audio)
});
} else if (audio instanceof ArrayBuffer) descriptors.push({
role: "input",
path: "audio",
mediaType: "audio",
mimeType: "audio/mpeg",
bytes: audio.slice(0)
});
else if (typeof Blob !== "undefined" && audio instanceof Blob) descriptors.push({
role: "input",
path: "audio",
mediaType: "audio",
mimeType: audio.type || "audio/mpeg",
bytes: audio
});
if (Array.isArray(output.segments) || Array.isArray(output.words)) descriptors.push({
role: "output",
path: "transcription",
mediaType: "json",
mimeType: "application/json",
json: output
});
}
return descriptors;
}
/**
* Reject hosts that only make sense as an SSRF target: loopback, link-local
* (including the cloud metadata address), private, and unique-local ranges.
*
* Applied to caller-supplied input URLs only. Provider result URLs skip it on
* purpose — a self-hosted or local provider legitimately returns a `localhost`
* URL, and those live inside the same trust boundary as the adapter itself.
*
* This checks IP *literals*. A hostname that resolves to a private address
* passes, which is why `allowInputUrl` is required rather than optional.
*/
function isBlockedInputHost(hostname) {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (host === "localhost" || host.endsWith(".localhost")) return true;
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
if (ipv4) {
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
if (a === 127 || a === 0 || a === 10) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
return false;
}
if (host === "::" || host === "::1") return true;
if (host.startsWith("fe80:")) return true;
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(host);
if (mappedDotted?.[1]) return isBlockedInputHost(mappedDotted[1]);
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
if (mappedHex?.[1] && mappedHex[2]) {
const high = Number.parseInt(mappedHex[1], 16);
const low = Number.parseInt(mappedHex[2], 16);
return isBlockedInputHost(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
}
return false;
}
/**
* Fail the stream once more than `maxBytes` have passed through, so an
* unexpectedly huge artifact can't fill the blob store.
*
* Only used when the response does NOT already bound itself — a chunked reply,
* or a content-encoded one whose declared length describes the compressed
* bytes. When `content-length` describes the body the store will drain, HTTP
* framing is the bound and wrapping would only cost the caller the declared
* length: a `TransformStream`'s readable side carries none, which is what
* pushes a length-strict runtime (workerd + R2) onto a multipart upload.
*/
function capBodySize(body, maxBytes, url) {
let seen = 0;
return body.pipeThrough(new TransformStream({ transform(chunk, controller) {
seen += chunk.byteLength;
if (seen > maxBytes) {
controller.error(/* @__PURE__ */ new Error(`Artifact at ${url} exceeds maxArtifactBytes (${maxBytes}).`));
return;
}
controller.enqueue(chunk);
} }));
}
/**
* Resolve a descriptor to the bytes to store. Returns `undefined` when the
* descriptor is deliberately not persisted — today that means a caller-supplied
* input URL without an `allowInputUrl` opt-in.
*/
async function descriptorBody(descriptor, opts) {
if (descriptor.json !== void 0) {
const body = JSON.stringify(descriptor.json);
return {
body,
size: new TextEncoder().encode(body).byteLength,
mimeType: descriptor.mimeType ?? "application/json"
};
}
if (descriptor.bytes !== void 0) {
const body = descriptor.bytes;
let size;
if (typeof body === "string") size = new TextEncoder().encode(body).byteLength;
else if (body instanceof ArrayBuffer) size = body.byteLength;
else if (ArrayBuffer.isView(body)) size = body.byteLength;
else if (typeof Blob !== "undefined" && body instanceof Blob) size = body.size;
else size = 0;
return {
body,
size,
mimeType: descriptor.mimeType ?? "application/octet-stream"
};
}
if (descriptor.url) {
const data = parseDataUrl(descriptor.url);
if (data) return {
body: data.bytes,
size: data.bytes.byteLength,
mimeType: descriptor.mimeType ?? data.mimeType
};
const isCallerSupplied = descriptor.role === "input";
const allowInputUrl = opts?.allowInputUrl;
if (isCallerSupplied && !allowInputUrl) return void 0;
let target;
try {
target = new URL(descriptor.url);
} catch {
throw new Error(`Failed to persist artifact: ${descriptor.url} is not a valid URL.`);
}
if (target.protocol !== "https:" && target.protocol !== "http:") throw new Error(`Refusing to fetch artifact over ${target.protocol} (${descriptor.path}).`);
if (allowInputUrl && isCallerSupplied) {
if (isBlockedInputHost(target.hostname)) throw new Error(`Refusing to fetch input artifact from internal host ${target.hostname}.`);
if (!await allowInputUrl({
url: target,
descriptor
})) throw new Error(`Refusing to fetch input artifact from ${target.hostname}: rejected by allowInputUrl.`);
}
const maxBytes = opts?.maxArtifactBytes ?? DEFAULT_MAX_ARTIFACT_BYTES;
const response = await (opts?.artifactFetch ?? globalThis.fetch)(target, {
redirect: isCallerSupplied ? "manual" : "follow",
signal: AbortSignal.timeout(opts?.artifactFetchTimeoutMs ?? DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS)
});
if (isCallerSupplied && response.status >= 300 && response.status < 400) throw new Error(`Refusing to follow a redirect for input artifact ${descriptor.path}.`);
if (!response.ok) throw new Error(`Failed to persist artifact from ${descriptor.url}: HTTP ${response.status}`);
const contentLength = response.headers.get("content-length");
const declaredLength = contentLength === null ? void 0 : Number(contentLength);
if (maxBytes !== false && declaredLength !== void 0 && Number.isFinite(declaredLength) && declaredLength > maxBytes) throw new Error(`Artifact at ${descriptor.url} exceeds maxArtifactBytes (${maxBytes}).`);
const mimeType = descriptor.mimeType ?? response.headers.get("content-type") ?? "application/octet-stream";
const encoding = response.headers.get("content-encoding");
const decodedLengthIsKnown = declaredLength !== void 0 && Number.isFinite(declaredLength) && (encoding === null || encoding === "identity");
const expectedLength = decodedLengthIsKnown ? declaredLength : void 0;
if (response.body) return {
body: maxBytes === false || decodedLengthIsKnown ? response.body : capBodySize(response.body, maxBytes, descriptor.url),
size: 0,
expectedLength,
mimeType,
sourceUrl: descriptor.url
};
const body = await response.arrayBuffer();
if (maxBytes !== false && body.byteLength > maxBytes) throw new Error(`Artifact at ${descriptor.url} exceeds maxArtifactBytes (${maxBytes}).`);
return {
body,
size: body.byteLength,
mimeType,
sourceUrl: descriptor.url
};
}
throw new Error(`Artifact descriptor ${descriptor.path} has no bytes, url, or json.`);
}
async function persistGenerationArtifacts(persistence, opts, ctx, result) {
const activity = mediaActivity(ctx.activity);
if (!activity) return [];
const threadId = generationScope(ctx, opts);
const runId = ctx.runId ?? ctx.requestId;
const extractionInput = {
activity,
provider: ctx.provider,
model: ctx.model,
threadId,
runId,
inputs: ctx.artifactInputs,
result
};
const extracted = opts?.extractArtifacts !== void 0 ? await opts.extractArtifacts(extractionInput) : builtInArtifactDescriptors(activity, ctx.artifactInputs, result);
if (extracted.length === 0) return [];
const existingRefs = extracted.filter(isArtifactRef);
const descriptors = extracted.filter((item) => !isArtifactRef(item));
if (descriptors.length === 0) return existingRefs;
if (!persistence.stores.artifacts || !persistence.stores.blobs) throw new Error("Generation artifact persistence requires stores.artifacts and stores.blobs.");
const refs = [...existingRefs];
for (const [index, descriptor] of descriptors.entries()) {
const artifactId = ctx.createId("artifact");
const resolved = await descriptorBody(descriptor, opts);
if (!resolved) continue;
const { body, size, expectedLength, mimeType, sourceUrl } = resolved;
const name = opts?.nameArtifact?.({
descriptor: {
...descriptor,
mimeType
},
activity,
provider: ctx.provider,
model: ctx.model,
threadId,
runId,
index
}) ?? descriptor.name ?? defaultArtifactName({
...descriptor,
mimeType
}, activity, index);
const key = opts?.storageKey?.({
artifactId,
runId,
threadId,
role: descriptor.role,
activity,
path: descriptor.path,
mimeType,
name
}) ?? artifactBlobKey({
runId,
artifactId
});
const stored = await persistence.stores.blobs.put(key, body, {
contentType: mimeType,
...expectedLength !== void 0 ? { expectedLength } : {},
customMetadata: {
runId,
threadId,
role: descriptor.role,
activity,
path: descriptor.path
}
});
const resolvedSize = size || stored.size || 0;
const createdAtMs = Date.now();
const record = {
artifactId,
runId,
threadId,
blobKey: key,
name,
mimeType,
size: resolvedSize,
sourceUrl,
createdAt: createdAtMs
};
await persistence.stores.artifacts.save(record);
refs.push({
role: descriptor.role,
artifactId,
threadId,
runId,
name,
mimeType,
size: resolvedSize,
createdAt: new Date(createdAtMs).toISOString(),
...sourceUrl ? { sourceUrl } : {},
source: {
activity,
path: descriptor.path,
provider: ctx.provider,
model: ctx.model,
mediaType: descriptor.mediaType,
jobId: descriptor.jobId,
expiresAt: descriptor.expiresAt instanceof Date ? descriptor.expiresAt.toISOString() : descriptor.expiresAt
}
});
}
if (opts?.artifactUrl) for (let i = 0; i < refs.length; i++) {
const ref = refs[i];
if (ref && !ref.url) {
const url = opts.artifactUrl(ref);
if (url) refs[i] = {
...ref,
url
};
}
}
return refs;
}
/**
* Rewrite the live result's media fields to each output ref's durable serve URL
* (`ref.url`), so the live result matches what a reload restores. Keyed off the
* ref's `source.path`: `images.<i>` → `result.images[i].url`, `video` →
* `result.url`, `audio` (object) → `result.audio.url`. tts (a base64 string) and
* transcription (json) have no media-URL field, so they are left as-is; their
* durable bytes are reachable via `result.artifacts`. A no-op when no ref has a
* `url`.
*/
function applyDurableMediaUrls(result, refs) {
let next = result;
for (const ref of refs) {
if (ref.role !== "output" || !ref.url) continue;
const path = ref.source.path;
if (path.startsWith("images.")) {
const index = Number(path.slice(7));
const images = next.images;
if (Array.isArray(images) && objectValue(images[index])) {
const cloned = [...images];
cloned[index] = {
...objectValue(images[index]),
url: ref.url
};
next = {
...next,
images: cloned
};
}
} else if (path === "video") next = {
...next,
url: ref.url
};
else if (path === "audio" && objectValue(next.audio)) next = {
...next,
audio: {
...objectValue(next.audio),
url: ref.url
}
};
}
return next;
}
function resolvePersistencePlan(persistence) {
return {
wantsInterrupts: persistence.stores.interrupts !== void 0,
wantsArtifactPersistence: persistence.stores.artifacts !== void 0 && persistence.stores.blobs !== void 0,
runs: persistence.stores.runs
};
}
async function createOrResumeRun(runs, runId, threadId) {
return (await runs?.createOrResume({
runId,
threadId,
startedAt: Date.now()
}))?.usage;
}
function sumOptionalNumber(current, next) {
if (current === void 0) return next;
if (next === void 0) return current;
return current + next;
}
function sumNumberFields(current, next) {
if (!current) return next;
if (!next) return current;
const result = { ...current };
for (const key of Object.keys(next)) {
const currentValue = current[key];
const nextValue = next[key];
if (typeof nextValue === "number") result[key] = (typeof currentValue === "number" ? currentValue : 0) + nextValue;
}
return result;
}
function tokenUsageFromChunk(chunk) {
if (chunk.type !== "RUN_FINISHED" && chunk.type !== "RUN_ERROR") return;
const usage = chunk.usage;
if (usage != null && typeof usage === "object" && !Array.isArray(usage) && "promptTokens" in usage) return usage;
const metadata = chunk.metadata;
const tanstack = metadata != null && typeof metadata === "object" && "tanstack" in metadata ? metadata.tanstack : void 0;
const leftover = tanstack != null && typeof tanstack === "object" && !Array.isArray(tanstack) ? tanstack.usage : void 0;
return fromSpecTokenUsage(Array.isArray(usage) ? usage : void 0, leftover);
}
function accumulateTokenUsage(current, next) {
if (!current) return { ...next };
const promptTokensDetails = sumNumberFields(current.promptTokensDetails, next.promptTokensDetails);
const completionTokensDetails = sumNumberFields(current.completionTokensDetails, next.completionTokensDetails);
const costDetails = sumNumberFields(current.costDetails, next.costDetails);
const providerUsageDetails = next.providerUsageDetails ?? current.providerUsageDetails;
const durationSeconds = sumOptionalNumber(current.durationSeconds, next.durationSeconds);
const unitsBilled = sumOptionalNumber(current.unitsBilled, next.unitsBilled);
const billed = accumulateBilled(current.billed, next.billed);
const cost = sumOptionalNumber(current.cost, next.cost);
return {
...current,
...next,
promptTokens: current.promptTokens + next.promptTokens,
completionTokens: current.completionTokens + next.completionTokens,
totalTokens: current.totalTokens + next.totalTokens,
...promptTokensDetails ? { promptTokensDetails } : {},
...completionTokensDetails ? { completionTokensDetails } : {},
...durationSeconds !== void 0 ? { durationSeconds } : {},
...unitsBilled !== void 0 ? { unitsBilled } : {},
...billed !== void 0 ? { billed } : {},
...cost !== void 0 ? { cost } : {},
...costDetails ? { costDetails } : {},
...providerUsageDetails ? { providerUsageDetails } : {}
};
}
/**
* Sum billed quantities when both reports use the same unit. Different units
* cannot be added, so the later report wins.
*/
function accumulateBilled(current, next) {
if (!current) return next;
if (!next) return current;
if (current.unit !== next.unit) return next;
return {
quantity: current.quantity + next.quantity,
unit: current.unit
};
}
async function completeRun(runs, runId, usage) {
await runs?.update(runId, {
status: "completed",
finishedAt: Date.now(),
detachedSince: void 0,
...usage ? { usage } : {}
});
}
async function failRun(runs, runId, error, usage) {
const runError = toRunErrorPayload(error);
await runs?.update(runId, {
status: "failed",
finishedAt: Date.now(),
detachedSince: void 0,
error: {
message: runError.message,
...runError.code !== void 0 ? { code: runError.code } : {}
},
...usage ? { usage } : {}
});
}
/**
* Record a human-in-the-loop PAUSE.
*
* Deliberately writes NO `finishedAt`: `'interrupted'` is not a terminal status
* (`isTerminalRunStatus('interrupted')` is `false`), and stamping a terminal
* timestamp on it told every reader the run was over while it was in fact
* waiting for a human. Only `abortRun`/`completeRun`/`failRun` finish a run.
*/
async function interruptRun(runs, runId, usage) {
await runs?.update(runId, {
status: "interrupted",
...usage ? { usage } : {}
});
}
/**
* Record that the run has ended for good — an explicit cancel, or a disconnect
* on a run that has nothing to reattach to. Terminal, so it carries
* `finishedAt`.
*/
async function abortRun(runs, runId, usage) {
await runs?.update(runId, {
status: "aborted",
finishedAt: Date.now(),
detachedSince: void 0,
...usage ? { usage } : {}
});
}
/**
* Whether some middleware has declared this run detachable — i.e. it has a
* durable event log and a run store, so a disconnect can be survived and the
* run picked back up rather than destroyed.
*
* The capability is read from CORE, never from `@tanstack/ai-sandbox`: sandbox
* provides it, persistence consumes it, and a persistence → sandbox import
* would invert the layering.
*/
function detachableRun(ctx) {
return getDetachableRun(ctx, { optional: true }) === true;
}
/**
* @param persistence - Must satisfy {@link ChatTranscriptStores} (messages
* required). Known-absent `messages` or `interrupts` without `runs` fail at
* compile time; fully dynamic bags are checked at runtime.
*/
function withPersistence(persistence, options = {}) {
validateChatPersistenceStores(persistence);
const snapshotStreaming = options.snapshotStreaming ?? false;
const snapshotIntervalMs = options.snapshotIntervalMs ?? 1e3;
const { wantsInterrupts, runs } = resolvePersistencePlan(persistence);
const messageStore = persistence.stores.messages;
if (!messageStore) throw new Error("Chat persistence requires stores.messages.");
const provides = [
PersistenceCapability,
PersistenceCompletionCapability,
...persistence.stores.metadata ? [MetadataCapability] : [],
...wantsInterrupts ? [InterruptsCapability] : []
];
return defineChatMiddleware({
name: "chat-persistence",
provides,
setup(ctx) {
providePersistence(ctx, persistence);
if (persistence.stores.metadata) provideMetadata(ctx, persistence.stores.metadata);
let resolveCompletion = () => void 0;
let rejectCompletion = () => void 0;
const completion = new Promise((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
completion.catch(() => void 0);
runState.set(ctx, {
merged: false,
interrupted: false,
completion: {
promise: completion,
resolve: resolveCompletion,
reject: rejectCompletion
}
});
providePersistenceCompletion(ctx, { waitForRunCompletion: () => completion });
if (wantsInterrupts && persistence.stores.interrupts) provideInterrupts(ctx, persistence.stores.interrupts);
providePendingTurn(ctx, { snapshot: async () => {
const stored = await messageStore.loadThread(ctx.threadId);
const list = ctx.messages.length > 0 ? [...ctx.messages] : stored;
await messageStore.saveThread(ctx.threadId, list);
} });
},
async onConfig(ctx, config) {
if (ctx.phase !== "init") return;
const patch = {};
if (wantsInterrupts && persistence.stores.interrupts) {
const ownedPending = (await persistence.stores.interrupts.listPending(ctx.threadId)).filter(isChatOwnedPendingInterrupt);
rejectMixedRunPending(ownedPending, ctx);
const resumeByInterruptId = validatePendingResumes(ownedPending, config.resume, ctx);
if ((config.resume?.length ?? 0) > 0) {
const resumeToolState = resumeToolStateFromPending(ownedPending, resumeByInterruptId);
const genericResumeState = await durableGenericResumeState(ctx, ownedPending, config.resume ?? [], config.tools);
patch.resume = [];
if (resumeToolState || genericResumeState) patch.resumeToolState = mergeResumeToolState(resumeToolState, genericResumeState);
}
const state = runState.get(ctx);
if (state && ownedPending.length > 0) state.pendingResumes = {
pending: ownedPending,
resumeByInterruptId
};
}
const storedUsage = await createOrResumeRun(runs, ctx.runId, ctx.threadId);
const state = runState.get(ctx);
if (state && storedUsage) state.usage = storedUsage;
if (!state?.merged) {
if (state) state.merged = true;
const stored = await messageStore.loadThread(ctx.threadId);
patch.messages = config.messages.length > 0 ? config.messages : stored;
}
return Object.keys(patch).length > 0 ? patch : void 0;
},
async onStart(ctx) {
try {
await messageStore.saveThread(ctx.threadId, [...ctx.messages]);
} catch {}
},
async onChunk(ctx, chunk) {
if (snapshotStreaming && ctx.phase === "modelStream") {
const s = runState.get(ctx);
if (s && chunk.type === "TEXT_MESSAGE_START") {
s.streamingMessageId = typeof chunk.messageId === "string" && chunk.messageId !== "" ? chunk.messageId : void 0;
s.streamingMessageCreatedAt = /* @__PURE__ */ new Date();
s.streamingText = "";
} else if (s && chunk.type === "TOOL_CALL_START" && typeof chunk.parentMessageId === "string" && chunk.parentMessageId !== "" && s.streamingMessageId === void 0) {
s.streamingMessageId = chunk.parentMessageId;
s.streamingMessageCreatedAt ??= /* @__PURE__ */ new Date();
}
}
if (snapshotStreaming && chunk.type === "TEXT_MESSAGE_CONTENT" && typeof chunk.delta === "string") {
const snapshotState = runState.get(ctx);
if (snapshotState) {
snapshotState.streamingText = (snapshotState.streamingText ?? "") + chunk.delta;
const now = Date.now();
if (now - (snapshotState.lastSnapshotAt ?? 0) >= snapshotIntervalMs) {
snapshotState.lastSnapshotAt = now;
try {
await messageStore.saveThread(ctx.threadId, [...ctx.messages, {
role: "assistant",
content: snapshotState.streamingText,
...snapshotState.streamingMessageId ? { id: snapshotState.streamingMessageId } : {},
...snapshotState.streamingMessageCreatedAt ? { createdAt: snapshotState.streamingMessageCreatedAt } : {}
}]);
} catch {}
}
}
}
if (chunk.type !== "RUN_FINISHED" || chunk.outcome?.type !== "interrupt") return;
const state = runState.get(ctx);
if (!state) return;
if (wantsInterrupts && persistence.stores.interrupts) {
await commitPendingResumes(state, persistence.stores.interrupts);
for (const interrupt of chunk.outcome.interrupts) await persistence.stores.interrupts.create({
interruptId: interrupt.id,
runId: ctx.runId,
threadId: ctx.threadId,
requestedAt: Date.now(),
payload: interruptPayload(interrupt)
});
}
const chunkUsage = tokenUsageFromChunk(chunk);
const usage = ctx.phase === "modelStream" && chunkUsage ? accumulateTokenUsage(state.usage, chunkUsage) : state.usage ?? chunkUsage;
state.usage = usage;
await interruptRun(runs, ctx.runId, usage);
await messageStore.saveThread(ctx.threadId, [...ctx.messages]);
state.interrupted = true;
},
onUsage(ctx, usage) {
const state = runState.get(ctx);
if (!state || state.interrupted) return;
state.usage = accumulateTokenUsage(state.usage, usage);
},
async onFinish(ctx, info) {
const state = runState.get(ctx);
if (state?.interrupted) return;
try {
await messageStore.saveThread(ctx.threadId, [...ctx.messages]);
await commitPendingResumes(state, persistence.stores.interrupts);
await completeRun(runs, ctx.runId, state?.usage ?? info.usage);
state?.completion?.resolve();
} catch (error) {
try {
await failRun(runs, ctx.runId, error, state?.usage);
} finally {
state?.completion?.reject(error);
}
throw error;
}
},
async onError(ctx, info) {
try {
await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage);
} finally {
runState.get(ctx)?.completion?.reject(info.error);
}
},
async onAbort(ctx, info) {
const state = runState.get(ctx);
let terminal = false;
try {
terminal = info.cancelRequested === true || runs !== void 0 && await wasCancelRequested(runs, ctx.runId) || !detachableRun(ctx) && state?.interrupted !== true;
if (terminal) await abortRun(runs, ctx.runId, state?.usage);
} finally {
if (terminal) state?.completion?.reject(info.reason);
}
}
});
}
function withGenerationPersistence(persistence, opts = {}) {
validateGenerationPersistenceStores(persistence);
const { wantsArtifactPersistence } = resolvePersistencePlan(persistence);
const generationRuns = persistence.stores.generationRuns;
if (!generationRuns) throw new Error("Generation persistence requires stores.generationRuns.");
const runIdOf = (ctx) => ctx.runId ?? ctx.requestId;
return {
name: "generation-persistence",
async onStart(ctx) {
const runId = runIdOf(ctx);
await generationRuns.createOrResume({
runId,
activity: ctx.activity,
provider: ctx.provider,
model: ctx.model,
startedAt: Date.now(),
threadId: generationScope(ctx, opts)
});
if (wantsArtifactPersistence) ctx.resultTransforms?.push(async (result) => {
const refs = await persistGenerationArtifacts(persistence, opts, ctx, result);
if (refs.length === 0) return void 0;
const base = objectValue(result) ?? {};
const existing = base.artifacts;
return applyDurableMediaUrls({
...base,
artifacts: [...Array.isArray(existing) ? existing : [], ...refs]
}, refs);
});
ctx.resultTransforms?.push(async (result) => {
const rawArtifacts = objectValue(result)?.artifacts;
const artifacts = Array.isArray(rawArtifacts) ? rawArtifacts.filter(isArtifactRef) : [];
await generationRuns.update(runId, {
result,
...artifacts.length > 0 ? { artifacts } : {}
});
});
},
async onFinish(ctx, info) {
await generationRuns.update(runIdOf(ctx), {
status: "completed",
finishedAt: Date.now(),
...info.usage ? { usage: info.usage } : {}
});
},
async onError(ctx, info) {
await generationRuns.update(runIdOf(ctx), {
status: "failed",
finishedAt: Date.now(),
error: { message: info.error instanceof Error ? info.error.message : String(info.error) }
});
},
async onAbort(ctx, _info) {
await generationRuns.update(runIdOf(ctx), {
status: "aborted",
finishedAt: Date.now()
});
}
};
}
//#endregion
export { abortRun, interruptRun, withGenerationPersistence, withPersistence };
//# sourceMappingURL=middleware.js.map