UNPKG

@tanstack/ai

Version:

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

438 lines (437 loc) 17.9 kB
import { canonicalInterruptJson, digestInterruptJson } from "./interrupt-serialization.js"; import { canonicalizeInterruptResolutions } from "./interrupts.js"; import { isStandardSchema, validateWithStandardSchema } from "./activities/chat/tools/schema-converter.js"; import { hashSchemaInput, normalizeApprovalSchema } from "./activities/chat/tools/approval-schema.js"; //#region src/interrupt-resume.ts /** * The `Interrupt.metadata` key under which this package's resume binding * travels. * * Exported so anything that produces an interrupt this package must later * resume — an application middleware raising a generic pause, a future * workflow-to-AG-UI projection — attaches the binding through * {@link withInterruptBinding} rather than copying the string. Everything * outside this key is the plain AG-UI envelope and is left untouched. */ var INTERRUPT_BINDING_METADATA_KEY = "tanstack:interruptBinding"; var interruptBindingMetadataKey = INTERRUPT_BINDING_METADATA_KEY; var InterruptResumeValidationError = class extends Error { errors; name = "InterruptResumeValidationError"; constructor(errors) { super(errors.map((error) => error.message).join(" ")); this.errors = errors; } }; function objectValue(value) { return value && typeof value === "object" ? value : null; } function stringField(value, key) { return typeof value[key] === "string" ? value[key] : void 0; } function normalizeIssuePath(path) { if (!path) return void 0; return path.map((segment) => { if (typeof segment === "string" || typeof segment === "number") return segment; const key = objectValue(segment)?.key; return typeof key === "number" ? key : String(key ?? segment); }); } function interruptItemError(input, interruptId, code, message, options) { return { scope: "item", threadId: input.threadId, interruptedRunId: input.interruptedRunId, generation: input.generation, interruptId, code, message, source: options?.source ?? "client", retryable: options?.retryable ?? false, ...options?.path ? { path: options.path } : {} }; } async function validateSchemaValue(input) { if (isStandardSchema(input.schema)) { const result = await validateWithStandardSchema(input.schema, input.value); if (!result.success) for (const issue of result.issues) input.onIssue(issue.message, normalizeIssuePath(issue.path)); return; } } function runtimeTool(tools, name) { return tools.find((tool) => tool.name === name); } function descriptorResponseSchema(record) { return objectValue(record.payload)?.responseSchema; } function schemaHash(schema) { return digestInterruptJson(canonicalInterruptJson(schema)); } async function pushSchemaIssues(input) { try { await validateSchemaValue({ schema: input.schema, value: input.value, onIssue: (message, path) => { input.errors.push(interruptItemError(input.request, input.interruptId, input.code, `${input.label}: ${message}`, { path })); } }); } catch (error) { input.errors.push(interruptItemError(input.request, input.interruptId, "invalid-response-schema", `${input.label} could not be validated: ${error instanceof Error ? error.message : String(error)}`, { source: "server" })); } } function validateDescriptorSchema(input, record, binding, errors) { const schema = descriptorResponseSchema(record); if (schema === void 0 || schemaHash(schema) !== binding.responseSchemaHash) errors.push(interruptItemError(input, record.interruptId, "invalid-response-schema", `Interrupt ${record.interruptId} response schema no longer matches its binding.`, { source: "server" })); return schema; } /** * Validate and translate a complete interrupt batch before any tool executes. * Used by ephemeral chat resume; a durable layer may share the same validator. */ async function validateInterruptResumeBatch(input) { const grouped = /* @__PURE__ */ new Map(); const batchErrors = []; const group = (interruptId) => { const existing = grouped.get(interruptId); if (existing) return existing; const created = []; grouped.set(interruptId, created); return created; }; const pendingById = new Map(input.pending.map((record) => [record.interruptId, record])); const resumeById = /* @__PURE__ */ new Map(); const counts = /* @__PURE__ */ new Map(); for (const entry of input.resume ?? []) { counts.set(entry.interruptId, (counts.get(entry.interruptId) ?? 0) + 1); if (!resumeById.has(entry.interruptId)) resumeById.set(entry.interruptId, entry); } for (const [interruptId, count] of counts) if (count > 1) group(interruptId).push(interruptItemError(input, interruptId, "conflict", `Interrupt ${interruptId} has duplicate resume entries.`)); let incomplete = false; for (const record of input.pending) { const errors = group(record.interruptId); const entry = resumeById.get(record.interruptId); const binding = record.binding; if (!entry) { incomplete = true; errors.push(interruptItemError(input, record.interruptId, "unknown-interrupt", `Missing resume entry for interrupt ${record.interruptId}.`)); } if (binding.interruptedRunId !== input.interruptedRunId || binding.generation !== input.generation || binding.interruptId !== record.interruptId) errors.push(interruptItemError(input, record.interruptId, "stale", `Interrupt ${record.interruptId} has stale correlation metadata.`, { source: "server" })); if (binding.expiresAt !== void 0 && Date.parse(binding.expiresAt) <= (input.now ?? Date.now())) errors.push(interruptItemError(input, record.interruptId, "expired", `Interrupt ${record.interruptId} has expired.`, { source: "server" })); const responseSchema = validateDescriptorSchema(input, record, binding, errors); if (!entry) continue; const entryStatus = entry.status; if (entryStatus !== "resolved" && entryStatus !== "cancelled") { errors.push(interruptItemError(input, record.interruptId, "invalid-payload", `Interrupt ${record.interruptId} has invalid status ${String(entryStatus)}.`)); continue; } if (binding.kind === "generic") { if (entry.status === "cancelled") { if (entry.payload !== void 0) errors.push(interruptItemError(input, record.interruptId, "invalid-payload", `Cancelled interrupt ${record.interruptId} must not include a payload.`)); } else if (responseSchema !== void 0) await pushSchemaIssues({ request: input, errors, interruptId: record.interruptId, schema: responseSchema, value: entry.payload, code: "invalid-payload", label: `Interrupt ${record.interruptId} payload is invalid` }); continue; } const tool = runtimeTool(input.tools, binding.toolName); if (!tool) { errors.push(interruptItemError(input, record.interruptId, "stale", `Tool ${binding.toolName} is unavailable for interrupt ${record.interruptId}.`, { source: "server" })); continue; } let approval; let schemaDrifted = false; if (binding.kind === "client-tool-execution") { if (hashSchemaInput(tool.outputSchema) !== binding.outputSchemaHash) { errors.push(interruptItemError(input, record.interruptId, "stale", `Tool ${binding.toolName} output schema has changed.`, { source: "server" })); schemaDrifted = true; } } else { try { approval = normalizeApprovalSchema(tool.approvalSchema, tool.inputSchema); } catch { errors.push(interruptItemError(input, record.interruptId, "stale", `Tool ${binding.toolName} approval schema is unavailable.`, { source: "server" })); schemaDrifted = true; } if (approval !== void 0 && (hashSchemaInput(tool.inputSchema) !== binding.inputSchemaHash || approval.approvalSchemaHash !== binding.approvalSchemaHash || approval.responseSchemaHash !== binding.responseSchemaHash)) { errors.push(interruptItemError(input, record.interruptId, "stale", `Tool ${binding.toolName} approval schema has changed.`, { source: "server" })); schemaDrifted = true; } } if (entry.status === "cancelled") { if (entry.payload !== void 0) errors.push(interruptItemError(input, record.interruptId, "invalid-payload", `Cancelled interrupt ${record.interruptId} must not include a payload.`)); continue; } if (schemaDrifted) continue; if (binding.kind === "client-tool-execution") { if (responseSchema !== void 0) await pushSchemaIssues({ request: input, errors, interruptId: record.interruptId, schema: responseSchema, value: entry.payload, code: "invalid-tool-output", label: `Tool ${binding.toolName} output is invalid` }); if (tool.outputSchema !== void 0) await pushSchemaIssues({ request: input, errors, interruptId: record.interruptId, schema: tool.outputSchema, value: entry.payload, code: "invalid-tool-output", label: `Tool ${binding.toolName} output is invalid` }); continue; } if (approval === void 0) continue; const envelope = objectValue(entry.payload); const approved = typeof entry.payload === "boolean" ? entry.payload : typeof envelope?.approved === "boolean" ? envelope.approved : void 0; if (approved === void 0) { errors.push(interruptItemError(input, record.interruptId, "invalid-payload", `Approval ${record.interruptId} must be a boolean or decision envelope.`)); continue; } if (envelope) await pushSchemaIssues({ request: input, errors, interruptId: record.interruptId, schema: approval.responseSchema, value: entry.payload, code: "invalid-payload", label: `Approval ${record.interruptId} envelope is invalid` }); if (approved && envelope?.editedArgs !== void 0) if (tool.inputSchema === void 0) errors.push(interruptItemError(input, record.interruptId, "invalid-edited-args", `Approval ${record.interruptId} cannot edit arguments without an input schema.`)); else await pushSchemaIssues({ request: input, errors, interruptId: record.interruptId, schema: tool.inputSchema, value: envelope.editedArgs, code: "invalid-edited-args", label: `Approval ${record.interruptId} edited arguments are invalid` }); const branch = approved ? approval.branches.approve : approval.branches.reject; if (branch) if (!envelope) errors.push(interruptItemError(input, record.interruptId, "invalid-payload", `Approval ${record.interruptId} requires a payload for the ${approved ? "approve" : "reject"} decision.`)); else await pushSchemaIssues({ request: input, errors, interruptId: record.interruptId, schema: branch.source, value: envelope.payload, code: "invalid-payload", label: `Approval ${record.interruptId} payload is invalid` }); } for (const entry of input.resume ?? []) if (!pendingById.has(entry.interruptId)) { incomplete = true; group(entry.interruptId).push(interruptItemError(input, entry.interruptId, "unknown-interrupt", `Resume entry references unknown interrupt ${entry.interruptId}.`)); } if (incomplete) batchErrors.push({ scope: "batch", threadId: input.threadId, interruptedRunId: input.interruptedRunId, generation: input.generation, code: "incomplete-batch", message: "Resume entries must resolve or cancel the complete interrupt batch.", source: "client", retryable: false, interruptIds: input.pending.map((record) => record.interruptId) }); const itemErrors = [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right)).flatMap(([, errors]) => errors); if (itemErrors.length > 0) { batchErrors.push({ scope: "batch", threadId: input.threadId, interruptedRunId: input.interruptedRunId, generation: input.generation, code: "item-validation-failed", message: "One or more interrupt resolutions are invalid.", source: "client", retryable: false, interruptIds: input.pending.map((record) => record.interruptId) }); return { errors: [...itemErrors, ...batchErrors] }; } const canonical = canonicalizeInterruptResolutions(input.resume ?? []); const approvals = /* @__PURE__ */ new Map(); const clientToolResults = /* @__PURE__ */ new Map(); const genericInterrupts = /* @__PURE__ */ new Map(); const deniedToolResults = /* @__PURE__ */ new Map(); const cancelledToolCallIds = /* @__PURE__ */ new Set(); for (const record of input.pending) { const entry = resumeById.get(record.interruptId); if (!entry) continue; const binding = record.binding; if (binding.kind === "generic") { genericInterrupts.set(record.interruptId, entry.status === "resolved" ? { interruptId: record.interruptId, status: "resolved", payload: entry.payload } : { interruptId: record.interruptId, status: "cancelled" }); continue; } if (entry.status === "cancelled") { cancelledToolCallIds.add(binding.toolCallId); continue; } if (binding.kind === "client-tool-execution") { clientToolResults.set(binding.toolCallId, entry.payload); continue; } const envelope = objectValue(entry.payload); const resolution = typeof entry.payload === "boolean" ? entry.payload : envelope?.approved === true ? { approved: true, ...envelope.editedArgs !== void 0 ? { editedArgs: envelope.editedArgs } : {}, ...envelope.payload !== void 0 ? { payload: envelope.payload } : {} } : { approved: false, ...envelope?.payload !== void 0 ? { payload: envelope.payload } : {} }; approvals.set(binding.toolCallId, resolution); if (resolution === false || typeof resolution === "object" && !resolution.approved) deniedToolResults.set(binding.toolCallId, typeof resolution === "object" ? resolution.payload : void 0); } return { errors: [], resolutions: canonical.resolutions, canonicalResolutions: canonical.canonicalResolutions, fingerprint: canonical.fingerprint, resumeToolState: { approvals, clientToolResults, genericInterrupts, deniedToolResults, cancelledToolCallIds } }; } /** * Is this a binding written by a version of the protocol we understand? * * A missing `v` is read as {@link INTERRUPT_BINDING_VERSION} so bindings * written before the field existed still resume. A `v` we don't recognise is * rejected outright — a newer or foreign producer's binding must not be * duck-typed into ours. */ function isSupportedBindingVersion(raw) { const version = raw["v"]; if (version === void 0) return true; return version === 1; } function readUnopenedInterruptBinding(descriptor) { const metadata = objectValue(descriptor.metadata); const raw = metadata ? objectValue(metadata[interruptBindingMetadataKey]) : null; if (!raw || stringField(raw, "interruptId") !== descriptor.id) return void 0; if (!isSupportedBindingVersion(raw)) return void 0; const kind = stringField(raw, "kind"); const interruptId = stringField(raw, "interruptId"); const responseSchemaHash = stringField(raw, "responseSchemaHash"); const expiresAt = stringField(raw, "expiresAt"); if (!interruptId || !responseSchemaHash) return void 0; const v = 1; if (kind === "generic") return { v, kind, interruptId, responseSchemaHash, ...expiresAt ? { expiresAt } : {} }; const toolName = stringField(raw, "toolName"); const toolCallId = stringField(raw, "toolCallId"); if (!toolName || !toolCallId) return void 0; if (kind === "client-tool-execution") { const outputSchemaHash = stringField(raw, "outputSchemaHash"); if (!outputSchemaHash) return void 0; return { v, kind, interruptId, toolName, toolCallId, outputSchemaHash, responseSchemaHash, ...expiresAt ? { expiresAt } : {} }; } if (kind === "tool-approval") { const inputSchemaHash = stringField(raw, "inputSchemaHash"); const approvalSchemaHash = stringField(raw, "approvalSchemaHash"); if (!inputSchemaHash || !approvalSchemaHash) return void 0; return { v, kind, interruptId, toolName, toolCallId, originalArgs: raw.originalArgs, inputSchemaHash, approvalSchemaHash, responseSchemaHash, ...expiresAt ? { expiresAt } : {} }; } } /** * Attach a resume binding to an interrupt descriptor, under * {@link INTERRUPT_BINDING_METADATA_KEY}. * * This is the supported way to make an interrupt resumable by this package. * The descriptor keeps its AG-UI shape; only `metadata` gains the namespaced * key. Pass the unopened form (no `interruptedRunId` / `generation`) when * emitting from inside a run — those fields are stamped as the run finishes. */ function withInterruptBinding(descriptor, binding) { return { ...descriptor, metadata: { ...descriptor.metadata, [interruptBindingMetadataKey]: { ...binding, v: 1, interruptId: descriptor.id } } }; } /** * Read the opened resume binding off a descriptor, or `undefined` when the * descriptor carries no binding of a version we understand. * * `undefined` means "this interrupt is not ours to resume" — it is not a * failure to recover from by inventing a binding. */ function readInterruptBinding(descriptor) { const unopened = readUnopenedInterruptBinding(descriptor); if (!unopened) return void 0; const metadata = objectValue(descriptor.metadata); const raw = metadata ? objectValue(metadata[interruptBindingMetadataKey]) : null; if (!raw) return void 0; const interruptedRunId = stringField(raw, "interruptedRunId"); const generation = raw["generation"]; if (!interruptedRunId || typeof generation !== "number" || !Number.isInteger(generation) || generation < 0) return; return { ...unopened, interruptedRunId, generation }; } function withoutInterruptBinding(descriptor) { const metadata = objectValue(descriptor.metadata); if (!metadata || !(interruptBindingMetadataKey in metadata)) return descriptor; const publicMetadata = { ...metadata }; delete publicMetadata[interruptBindingMetadataKey]; return { ...descriptor, metadata: publicMetadata }; } //#endregion export { INTERRUPT_BINDING_METADATA_KEY, InterruptResumeValidationError, interruptItemError, readInterruptBinding, readUnopenedInterruptBinding, validateInterruptResumeBatch, withInterruptBinding, withoutInterruptBinding }; //# sourceMappingURL=interrupt-resume.js.map