@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
582 lines (551 loc) • 18.8 kB
text/typescript
import {
StreamProcessor,
convertMessagesToModelMessages,
modelMessagesToUIMessages,
subagentHostMessageId,
wireSubagentInfo,
} from '@tanstack/ai'
import type {
Interrupt,
ModelMessage,
RunAgentResumeItem,
RunStore,
StreamChunk,
SubagentStatus,
SubagentWireInfo,
UIMessage,
} from '@tanstack/ai'
import { mergeStoredMessages } from './merge-stored'
import type { InterruptStore, MessageStore } from './types'
function withSubagentInfo(
metadata: ModelMessage['metadata'],
info: SubagentWireInfo,
): Record<string, unknown> {
const source =
metadata != null && typeof metadata === 'object'
? (metadata as Record<string, unknown>)
: {}
const tanstack =
source.tanstack != null && typeof source.tanstack === 'object'
? (source.tanstack as Record<string, unknown>)
: {}
return { ...source, tanstack: { ...tanstack, subagent: info } }
}
function childStoreId(subagentRunId: string) {
return `subagent:${subagentRunId}`
}
function readSubagentRunId(chunk: StreamChunk) {
if (!('subagentRunId' in chunk)) return
const id = chunk.subagentRunId
return typeof id === 'string' && id !== '' ? id : undefined
}
function assistantId(runId: string) {
return subagentHostMessageId(runId)
}
function messageText(message: ModelMessage) {
return typeof message.content === 'string' ? message.content : ''
}
function readModelRunId(message: ModelMessage) {
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 : undefined
}
function withRunId(message: ModelMessage, runId: string): ModelMessage {
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: string, incoming: string) {
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))
}
// A live client mints its own assistant id. The stored row uses
// `assistant:${runId}`. When the client sends the turn again, copy `runId`
// onto the new row so the cards still rebuild.
function keepSubagentRunIds(
stored: ReadonlyArray<ModelMessage>,
merged: Array<ModelMessage>,
) {
const dropped = stored.filter((message) => {
const runId = readModelRunId(message)
if (runId === undefined) return false
return !merged.some(
(item) => item.id !== undefined && item.id === message.id,
)
})
// The user message before a message, as the turn it answers.
const turnOf = (list: ReadonlyArray<ModelMessage>, index: number) =>
list.slice(0, index).findLast((message) => message.role === 'user')?.id
const used = new Set<number>()
for (const previous of dropped) {
const runId = readModelRunId(previous)
if (runId === undefined) 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) !== undefined) return false
// No child text yet (a child waits for approval): match the first
// assistant reply to the same user message.
if (previousText === '') {
return (
previousTurn !== undefined &&
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: ReadonlyArray<UIMessage>) {
return messages
.flatMap((message) =>
message.role === 'assistant'
? message.parts.flatMap((part) =>
part.type === 'text' && part.content.trim() !== ''
? [part.content.trim()]
: [],
)
: [],
)
.join('\n\n')
}
// Nested children have their own run and transcript. Leave their cards out
// of this child's transcript, so a rebuilt card does not show them twice.
function withoutCards(messages: ReadonlyArray<UIMessage>): Array<UIMessage> {
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.
*/
export function storedSubagentInfo(
messages: ReadonlyArray<ModelMessage>,
): SubagentWireInfo | undefined {
// Same validation as the wire path. Malformed stored data reads as absent.
return wireSubagentInfo(messages[0])
}
type ChildNote = {
name: string
/** The thread and run that feed this note now. */
threadId: string
runId: string
/** Last transcript write, for the write interval. */
savedAt: number
/** The run that started this child. A resume keeps it. */
parentRunId: string
parentSubagentRunId?: string
parentToolCallId?: string
processor: StreamProcessor
status: SubagentStatus
interruptIds?: Array<string>
error?: { message: string; code?: string }
metadata?: Record<string, unknown>
}
export function createSubagentRunRecorder(stores: {
messages: MessageStore
runs?: RunStore
interrupts?: InterruptStore
/** Minimum milliseconds between writes while a child streams. */
intervalMs?: number
}) {
// One recorder serves every run of a middleware instance. Notes leave the
// map when their child or their run ends.
const children = new Map<string, ChildNote>()
const intervalMs = stores.intervalMs ?? 1000
// Resume entries each run answers. Committed when the run finishes or
// suspends. Dropped on abort.
const answered = new Map<string, Array<RunAgentResumeItem>>()
const parentSavedAt = new Map<string, number>()
async function loadMessages(threadId: string) {
return stores.messages.loadThread(threadId)
}
// The child and its ancestors, nearest first. Each keeps its own stream.
function lineage(subagentRunId: string) {
const notes: Array<ChildNote> = []
let id: string | undefined = subagentRunId
for (let depth = 0; id !== undefined && depth < 64; depth++) {
const note = children.get(id)
if (!note) break
notes.push(note)
id = note.parentSubagentRunId
}
return notes
}
async function saveChild(subagentRunId: string) {
const note = children.get(subagentRunId)
if (!note) return
note.savedAt = Date.now()
const info: SubagentWireInfo = {
name: note.name,
status: note.status,
...(note.parentSubagentRunId !== undefined && {
parentSubagentRunId: note.parentSubagentRunId,
}),
...(note.parentToolCallId !== undefined && {
parentToolCallId: note.parentToolCallId,
}),
...(note.interruptIds !== undefined && {
interruptIds: note.interruptIds,
}),
...(note.error !== undefined && { error: note.error }),
...(note.metadata !== undefined && { metadata: note.metadata }),
}
const transcript = convertMessagesToModelMessages(
withoutCards(note.processor.getMessages()),
)
const [first, ...rest] = transcript
const head: ModelMessage = first
? { ...first, metadata: withSubagentInfo(first.metadata, info) }
: {
id: `child:${subagentRunId}`,
role: 'assistant',
content: '',
metadata: withSubagentInfo(undefined, { ...info, placeholder: true }),
}
await stores.messages.saveThread(childStoreId(subagentRunId), [
head,
...rest,
])
}
// The parent message for a routed run holds the children's text. It exists
// even before any child writes text, so a reload can show a waiting card.
async function saveParent(threadId: string, runId: string) {
const notes = [...children.values()].filter(
(note) =>
note.parentRunId === runId &&
note.parentSubagentRunId === undefined &&
note.parentToolCallId === undefined,
)
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)
// A resume updates the message of the run that started the child.
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: ModelMessage = {
id: assistantId(runId),
role: 'assistant',
content,
metadata: { tanstack: { runId } },
}
await stores.messages.saveThread(threadId, [...stored, assistant])
}
async function startChild(
input: { threadId: string; runId: string },
chunk: Extract<StreamChunk, { type: 'SUBAGENT_STARTED' }>,
) {
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 !== undefined) existing.metadata = chunk.metadata
} else {
// A resume continues the stored transcript.
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 !== undefined && {
parentSubagentRunId: chunk.parentSubagentRunId,
}),
...(chunk.parentToolCallId !== undefined && {
parentToolCallId: chunk.parentToolCallId,
}),
...(chunk.metadata !== undefined && { 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' })
}
// The parent child shows this one as a nested card.
if (chunk.parentSubagentRunId !== undefined) {
children.get(chunk.parentSubagentRunId)?.processor.processChunk(chunk)
}
await saveChild(id)
}
async function settleChild(
chunk: Extract<
StreamChunk,
{ type: 'SUBAGENT_FINISHED' | 'SUBAGENT_ERROR' }
>,
) {
const id = chunk.subagentRunId
const note = children.get(id)
if (!note) return
try {
await writeSettled(note, id, chunk)
} finally {
// A routed child stays until its run ends: the parent message reads its
// text. Nested children and children started by a tool call go now.
if (
note.parentSubagentRunId !== undefined ||
note.parentToolCallId !== undefined
) {
children.delete(id)
}
}
}
async function writeSettled(
note: ChildNote,
id: string,
chunk: Extract<
StreamChunk,
{ type: 'SUBAGENT_FINISHED' | 'SUBAGENT_ERROR' }
>,
) {
note.processor.finalizeStream()
if (note.parentSubagentRunId !== undefined) {
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: string) {
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: string) {
return [...children].filter(([, note]) => note.runId === runId)
}
function forget(runId: string) {
for (const [id] of notesOf(runId)) children.delete(id)
parentSavedAt.delete(runId)
}
async function settleOpenChildren(
runId: string,
status: 'completed' | 'failed' | 'aborted',
error?: { message: string },
) {
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: string) {
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: {
threadId: string
runId: string
messages: ReadonlyArray<UIMessage | ModelMessage>
resume?: ReadonlyArray<RunAgentResumeItem>
}) {
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: {
threadId: string
runId: string
chunk: StreamChunk
}) {
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)
// A streaming child writes at most once per interval. Its terminal
// event and the run's end always write.
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: {
threadId: string
runId: string
interrupts: ReadonlyArray<Interrupt>
}) {
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: { threadId: string; runId: string }) {
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: { threadId: string; runId: string; error?: unknown }) {
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 ? undefined : { 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)
},
}
}