UNPKG

@tanstack/ai-persistence

Version:

Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.

366 lines (365 loc) • 13.5 kB
import { mergeStoredMessages } from "./merge-stored.js"; import { StreamProcessor, convertMessagesToModelMessages, modelMessagesToUIMessages, subagentHostMessageId, wireSubagentInfo } from "@tanstack/ai"; //#region src/subagent-runs.ts function withSubagentInfo(metadata, info) { const source = metadata != null && typeof metadata === "object" ? metadata : {}; const tanstack = source.tanstack != null && typeof source.tanstack === "object" ? source.tanstack : {}; return { ...source, tanstack: { ...tanstack, subagent: info } }; } function childStoreId(subagentRunId) { return `subagent:${subagentRunId}`; } function readSubagentRunId(chunk) { if (!("subagentRunId" in chunk)) return; const id = chunk.subagentRunId; return typeof id === "string" && id !== "" ? id : void 0; } function assistantId(runId) { return subagentHostMessageId(runId); } function messageText(message) { return typeof message.content === "string" ? message.content : ""; } function readModelRunId(message) { const metadata = message.metadata; if (metadata == null || typeof metadata !== "object") return; if (!("tanstack" in metadata)) return; const tanstack = metadata.tanstack; if (tanstack == null || typeof tanstack !== "object") return; if (!("runId" in tanstack)) return; const runId = tanstack.runId; return typeof runId === "string" && runId !== "" ? runId : void 0; } function withRunId(message, runId) { const metadata = message.metadata; const tanstack = metadata != null && typeof metadata === "object" && "tanstack" in metadata && metadata.tanstack != null && typeof metadata.tanstack === "object" ? metadata.tanstack : {}; return { ...message, metadata: { ...metadata, tanstack: { ...tanstack, runId } } }; } function sameChildText(stored, incoming) { if (stored === "" || incoming === "") return false; if (stored === incoming) return true; const blocks = stored.split("\n\n").filter((block) => block.includes(":\n")); if (blocks.length === 0) return false; return blocks.every((block) => incoming.includes(block)); } function keepSubagentRunIds(stored, merged) { const dropped = stored.filter((message) => { if (readModelRunId(message) === void 0) return false; return !merged.some((item) => item.id !== void 0 && item.id === message.id); }); const turnOf = (list, index) => list.slice(0, index).findLast((message) => message.role === "user")?.id; const used = /* @__PURE__ */ new Set(); for (const previous of dropped) { const runId = readModelRunId(previous); if (runId === void 0) continue; if (merged.some((message) => readModelRunId(message) === runId)) continue; const previousText = messageText(previous); const previousTurn = turnOf(stored, stored.indexOf(previous)); const index = merged.findIndex((message, messageIndex) => { if (used.has(messageIndex)) return false; if (message.role !== "assistant") return false; if (readModelRunId(message) !== void 0) return false; if (previousText === "") return previousTurn !== void 0 && turnOf(merged, messageIndex) === previousTurn && merged[messageIndex - 1]?.role === "user"; return sameChildText(previousText, messageText(message)); }); if (index === -1) continue; const host = merged[index]; if (!host) continue; used.add(index); merged[index] = withRunId(host, runId); } return merged; } function childText(messages) { return messages.flatMap((message) => message.role === "assistant" ? message.parts.flatMap((part) => part.type === "text" && part.content.trim() !== "" ? [part.content.trim()] : []) : []).join("\n\n"); } function withoutCards(messages) { return messages.map((message) => ({ ...message, parts: message.parts.filter((part) => part.type !== "subagent") })); } /** * Card data for a stored child transcript. It rides on the first message, in * `metadata.tanstack.subagent`, the same shape the wire uses. */ function storedSubagentInfo(messages) { return wireSubagentInfo(messages[0]); } function createSubagentRunRecorder(stores) { const children = /* @__PURE__ */ new Map(); const intervalMs = stores.intervalMs ?? 1e3; const answered = /* @__PURE__ */ new Map(); const parentSavedAt = /* @__PURE__ */ new Map(); async function loadMessages(threadId) { return stores.messages.loadThread(threadId); } function lineage(subagentRunId) { const notes = []; let id = subagentRunId; for (let depth = 0; id !== void 0 && depth < 64; depth++) { const note = children.get(id); if (!note) break; notes.push(note); id = note.parentSubagentRunId; } return notes; } async function saveChild(subagentRunId) { const note = children.get(subagentRunId); if (!note) return; note.savedAt = Date.now(); const info = { name: note.name, status: note.status, ...note.parentSubagentRunId !== void 0 && { parentSubagentRunId: note.parentSubagentRunId }, ...note.parentToolCallId !== void 0 && { parentToolCallId: note.parentToolCallId }, ...note.interruptIds !== void 0 && { interruptIds: note.interruptIds }, ...note.error !== void 0 && { error: note.error }, ...note.metadata !== void 0 && { metadata: note.metadata } }; const [first, ...rest] = convertMessagesToModelMessages(withoutCards(note.processor.getMessages())); const head = first ? { ...first, metadata: withSubagentInfo(first.metadata, info) } : { id: `child:${subagentRunId}`, role: "assistant", content: "", metadata: withSubagentInfo(void 0, { ...info, placeholder: true }) }; await stores.messages.saveThread(childStoreId(subagentRunId), [head, ...rest]); } async function saveParent(threadId, runId) { const notes = [...children.values()].filter((note) => note.parentRunId === runId && note.parentSubagentRunId === void 0 && note.parentToolCallId === void 0); if (notes.length === 0) return; const content = notes.map((note) => { const text = childText(note.processor.getMessages()); return text === "" ? "" : `${note.name}:\n${text}`; }).filter((block) => block !== "").join("\n\n"); const stored = await loadMessages(threadId); const index = stored.findIndex((message) => readModelRunId(message) === runId); const host = stored[index]; if (host) { const next = [...stored]; next[index] = { ...host, content }; await stores.messages.saveThread(threadId, next); return; } const assistant = { id: assistantId(runId), role: "assistant", content, metadata: { tanstack: { runId } } }; await stores.messages.saveThread(threadId, [...stored, assistant]); } async function startChild(input, chunk) { const id = chunk.subagentRunId; const record = await stores.runs?.createOrResume({ runId: id, threadId: childStoreId(id), startedAt: Date.now(), parentRunId: chunk.parentSubagentRunId ?? input.runId, subagentRunId: id, name: chunk.name }); const existing = children.get(id); if (existing) { existing.threadId = input.threadId; existing.runId = input.runId; existing.status = "running"; delete existing.interruptIds; if (chunk.metadata !== void 0) existing.metadata = chunk.metadata; } else { const stored = (await loadMessages(childStoreId(id))).filter((message) => storedSubagentInfo([message])?.placeholder !== true); children.set(id, { name: chunk.name, threadId: input.threadId, runId: input.runId, savedAt: 0, parentRunId: record?.parentRunId ?? chunk.parentSubagentRunId ?? input.runId, ...chunk.parentSubagentRunId !== void 0 && { parentSubagentRunId: chunk.parentSubagentRunId }, ...chunk.parentToolCallId !== void 0 && { parentToolCallId: chunk.parentToolCallId }, ...chunk.metadata !== void 0 && { metadata: chunk.metadata }, processor: new StreamProcessor({ subagentRunId: id, initialMessages: modelMessagesToUIMessages(stored) }), status: "running" }); } if (record && record.status !== "running") await stores.runs?.update(id, { status: "running" }); if (chunk.parentSubagentRunId !== void 0) children.get(chunk.parentSubagentRunId)?.processor.processChunk(chunk); await saveChild(id); } async function settleChild(chunk) { const id = chunk.subagentRunId; const note = children.get(id); if (!note) return; try { await writeSettled(note, id, chunk); } finally { if (note.parentSubagentRunId !== void 0 || note.parentToolCallId !== void 0) children.delete(id); } } async function writeSettled(note, id, chunk) { note.processor.finalizeStream(); if (note.parentSubagentRunId !== void 0) children.get(note.parentSubagentRunId)?.processor.processChunk(chunk); if (chunk.type === "SUBAGENT_ERROR") { const stopped = chunk.message === "Stopped"; note.status = "error"; note.error = { message: chunk.message }; await saveChild(id); await stores.runs?.update(id, { status: stopped ? "aborted" : "failed", finishedAt: Date.now(), ...!stopped ? { error: { message: chunk.message } } : {} }); return; } if (chunk.outcome?.type === "suspended") { note.status = "suspended"; note.interruptIds = chunk.outcome.interruptIds ?? []; await saveChild(id); await stores.runs?.update(id, { status: "interrupted" }); return; } note.status = "finished"; await saveChild(id); await stores.runs?.update(id, { status: "completed", finishedAt: Date.now() }); } async function commitAnswers(runId) { const entries = answered.get(runId) ?? []; answered.delete(runId); for (const entry of entries) if (entry.status === "cancelled") await stores.interrupts?.cancel(entry.interruptId); else await stores.interrupts?.resolve(entry.interruptId, entry.payload); } /** The notes a run fed. */ function notesOf(runId) { return [...children].filter(([, note]) => note.runId === runId); } function forget(runId) { for (const [id] of notesOf(runId)) children.delete(id); parentSavedAt.delete(runId); } async function settleOpenChildren(runId, status, error) { for (const [subagentRunId] of notesOf(runId)) { await saveChild(subagentRunId); const current = await stores.runs?.get(subagentRunId); if (current && current.status !== "running") continue; await stores.runs?.update(subagentRunId, { status, finishedAt: Date.now(), ...error ? { error } : {} }); } } /** Write the parent messages of this thread's routed children. */ async function saveParents(threadId) { const runIds = new Set([...children.values()].filter((note) => note.threadId === threadId).map((note) => note.parentRunId)); for (const runId of runIds) await saveParent(threadId, runId); } return { async start(input) { await stores.runs?.createOrResume({ runId: input.runId, threadId: input.threadId, startedAt: Date.now() }); if (input.resume?.length) answered.set(input.runId, [...input.resume]); const incoming = convertMessagesToModelMessages([...input.messages]); const stored = await loadMessages(input.threadId); const merged = mergeStoredMessages(stored, incoming); await stores.messages.saveThread(input.threadId, keepSubagentRunIds(stored, merged)); }, async chunk(input) { const chunk = input.chunk; if (chunk.type === "SUBAGENT_STARTED") { await startChild(input, chunk); return; } if (chunk.type === "SUBAGENT_FINISHED" || chunk.type === "SUBAGENT_ERROR") { await settleChild(chunk); await saveParents(input.threadId); return; } const subagentRunId = readSubagentRunId(chunk); if (!subagentRunId) return; const notes = lineage(subagentRunId); if (notes.length === 0) return; for (const note of notes) note.processor.processChunk(chunk); const now = Date.now(); const note = notes[0]; if (note && now - note.savedAt >= intervalMs) await saveChild(subagentRunId); if (chunk.type === "TEXT_MESSAGE_CONTENT" && now - (parentSavedAt.get(input.runId) ?? 0) >= intervalMs) { parentSavedAt.set(input.runId, now); await saveParents(input.threadId); } }, async suspend(input) { await commitAnswers(input.runId); await saveParents(input.threadId); for (const [subagentRunId] of notesOf(input.runId)) await saveChild(subagentRunId); for (const interrupt of input.interrupts) await stores.interrupts?.create({ interruptId: interrupt.id, runId: input.runId, threadId: input.threadId, requestedAt: Date.now(), payload: { ...interrupt } }); await stores.runs?.update(input.runId, { status: "interrupted" }); forget(input.runId); }, async finish(input) { await commitAnswers(input.runId); await saveParents(input.threadId); await settleOpenChildren(input.runId, "completed"); await stores.runs?.update(input.runId, { status: "completed", finishedAt: Date.now() }); forget(input.runId); }, async abort(input) { const aborted = input.error instanceof Error && input.error.name === "AbortError"; const message = input.error instanceof Error ? input.error.message : "Run failed"; await settleOpenChildren(input.runId, aborted ? "aborted" : "failed", aborted ? void 0 : { message }); await saveParents(input.threadId); await stores.runs?.update(input.runId, { status: aborted ? "aborted" : "failed", finishedAt: Date.now(), ...!aborted ? { error: { message } } : {} }); answered.delete(input.runId); forget(input.runId); } }; } //#endregion export { createSubagentRunRecorder, storedSubagentInfo }; //# sourceMappingURL=subagent-runs.js.map