@tanstack/ai-persistence 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,581 @@
1
+ import {
2
+ StreamProcessor,
3
+ convertMessagesToModelMessages,
4
+ modelMessagesToUIMessages,
5
+ subagentHostMessageId,
6
+ wireSubagentInfo,
7
+ } from '@tanstack/ai'
8
+ import type {
9
+ Interrupt,
10
+ ModelMessage,
11
+ RunAgentResumeItem,
12
+ RunStore,
13
+ StreamChunk,
14
+ SubagentStatus,
15
+ SubagentWireInfo,
16
+ UIMessage,
17
+ } from '@tanstack/ai'
18
+ import { mergeStoredMessages } from './merge-stored'
19
+ import type { InterruptStore, MessageStore } from './types'
20
+
21
+ function withSubagentInfo(
22
+ metadata: ModelMessage['metadata'],
23
+ info: SubagentWireInfo,
24
+ ): Record<string, unknown> {
25
+ const source =
26
+ metadata != null && typeof metadata === 'object'
27
+ ? (metadata as Record<string, unknown>)
28
+ : {}
29
+ const tanstack =
30
+ source.tanstack != null && typeof source.tanstack === 'object'
31
+ ? (source.tanstack as Record<string, unknown>)
32
+ : {}
33
+ return { ...source, tanstack: { ...tanstack, subagent: info } }
34
+ }
35
+
36
+ function childStoreId(subagentRunId: string) {
37
+ return `subagent:${subagentRunId}`
38
+ }
39
+
40
+ function readSubagentRunId(chunk: StreamChunk) {
41
+ if (!('subagentRunId' in chunk)) return
42
+ const id = chunk.subagentRunId
43
+ return typeof id === 'string' && id !== '' ? id : undefined
44
+ }
45
+
46
+ function assistantId(runId: string) {
47
+ return subagentHostMessageId(runId)
48
+ }
49
+
50
+ function messageText(message: ModelMessage) {
51
+ return typeof message.content === 'string' ? message.content : ''
52
+ }
53
+
54
+ function readModelRunId(message: ModelMessage) {
55
+ const metadata = message.metadata
56
+ if (metadata == null || typeof metadata !== 'object') return
57
+ if (!('tanstack' in metadata)) return
58
+ const tanstack = metadata.tanstack
59
+ if (tanstack == null || typeof tanstack !== 'object') return
60
+ if (!('runId' in tanstack)) return
61
+ const runId = tanstack.runId
62
+ return typeof runId === 'string' && runId !== '' ? runId : undefined
63
+ }
64
+
65
+ function withRunId(message: ModelMessage, runId: string): ModelMessage {
66
+ const metadata = message.metadata
67
+ const tanstack =
68
+ metadata != null &&
69
+ typeof metadata === 'object' &&
70
+ 'tanstack' in metadata &&
71
+ metadata.tanstack != null &&
72
+ typeof metadata.tanstack === 'object'
73
+ ? metadata.tanstack
74
+ : {}
75
+ return {
76
+ ...message,
77
+ metadata: {
78
+ ...metadata,
79
+ tanstack: { ...tanstack, runId },
80
+ },
81
+ }
82
+ }
83
+
84
+ function sameChildText(stored: string, incoming: string) {
85
+ if (stored === '' || incoming === '') return false
86
+ if (stored === incoming) return true
87
+ const blocks = stored.split('\n\n').filter((block) => block.includes(':\n'))
88
+ if (blocks.length === 0) return false
89
+ return blocks.every((block) => incoming.includes(block))
90
+ }
91
+
92
+ // A live client mints its own assistant id. The stored row uses
93
+ // `assistant:${runId}`. When the client sends the turn again, copy `runId`
94
+ // onto the new row so the cards still rebuild.
95
+ function keepSubagentRunIds(
96
+ stored: ReadonlyArray<ModelMessage>,
97
+ merged: Array<ModelMessage>,
98
+ ) {
99
+ const dropped = stored.filter((message) => {
100
+ const runId = readModelRunId(message)
101
+ if (runId === undefined) return false
102
+ return !merged.some(
103
+ (item) => item.id !== undefined && item.id === message.id,
104
+ )
105
+ })
106
+ // The user message before a message, as the turn it answers.
107
+ const turnOf = (list: ReadonlyArray<ModelMessage>, index: number) =>
108
+ list.slice(0, index).findLast((message) => message.role === 'user')?.id
109
+ const used = new Set<number>()
110
+ for (const previous of dropped) {
111
+ const runId = readModelRunId(previous)
112
+ if (runId === undefined) continue
113
+ if (merged.some((message) => readModelRunId(message) === runId)) continue
114
+ const previousText = messageText(previous)
115
+ const previousTurn = turnOf(stored, stored.indexOf(previous))
116
+ const index = merged.findIndex((message, messageIndex) => {
117
+ if (used.has(messageIndex)) return false
118
+ if (message.role !== 'assistant') return false
119
+ if (readModelRunId(message) !== undefined) return false
120
+ // No child text yet (a child waits for approval): match the first
121
+ // assistant reply to the same user message.
122
+ if (previousText === '') {
123
+ return (
124
+ previousTurn !== undefined &&
125
+ turnOf(merged, messageIndex) === previousTurn &&
126
+ merged[messageIndex - 1]?.role === 'user'
127
+ )
128
+ }
129
+ return sameChildText(previousText, messageText(message))
130
+ })
131
+ if (index === -1) continue
132
+ const host = merged[index]
133
+ if (!host) continue
134
+ used.add(index)
135
+ merged[index] = withRunId(host, runId)
136
+ }
137
+ return merged
138
+ }
139
+ function childText(messages: ReadonlyArray<UIMessage>) {
140
+ return messages
141
+ .flatMap((message) =>
142
+ message.role === 'assistant'
143
+ ? message.parts.flatMap((part) =>
144
+ part.type === 'text' && part.content.trim() !== ''
145
+ ? [part.content.trim()]
146
+ : [],
147
+ )
148
+ : [],
149
+ )
150
+ .join('\n\n')
151
+ }
152
+
153
+ // Nested children have their own run and transcript. Leave their cards out
154
+ // of this child's transcript, so a rebuilt card does not show them twice.
155
+ function withoutCards(messages: ReadonlyArray<UIMessage>): Array<UIMessage> {
156
+ return messages.map((message) => ({
157
+ ...message,
158
+ parts: message.parts.filter((part) => part.type !== 'subagent'),
159
+ }))
160
+ }
161
+
162
+ /**
163
+ * Card data for a stored child transcript. It rides on the first message, in
164
+ * `metadata.tanstack.subagent`, the same shape the wire uses.
165
+ */
166
+ export function storedSubagentInfo(
167
+ messages: ReadonlyArray<ModelMessage>,
168
+ ): SubagentWireInfo | undefined {
169
+ // Same validation as the wire path. Malformed stored data reads as absent.
170
+ return wireSubagentInfo(messages[0])
171
+ }
172
+
173
+ type ChildNote = {
174
+ name: string
175
+ /** The thread and run that feed this note now. */
176
+ threadId: string
177
+ runId: string
178
+ /** Last transcript write, for the write interval. */
179
+ savedAt: number
180
+ /** The run that started this child. A resume keeps it. */
181
+ parentRunId: string
182
+ parentSubagentRunId?: string
183
+ parentToolCallId?: string
184
+ processor: StreamProcessor
185
+ status: SubagentStatus
186
+ interruptIds?: Array<string>
187
+ error?: { message: string; code?: string }
188
+ metadata?: Record<string, unknown>
189
+ }
190
+
191
+ export function createSubagentRunRecorder(stores: {
192
+ messages: MessageStore
193
+ runs?: RunStore
194
+ interrupts?: InterruptStore
195
+ /** Minimum milliseconds between writes while a child streams. */
196
+ intervalMs?: number
197
+ }) {
198
+ // One recorder serves every run of a middleware instance. Notes leave the
199
+ // map when their child or their run ends.
200
+ const children = new Map<string, ChildNote>()
201
+ const intervalMs = stores.intervalMs ?? 1000
202
+ // Resume entries each run answers. Committed when the run finishes or
203
+ // suspends. Dropped on abort.
204
+ const answered = new Map<string, Array<RunAgentResumeItem>>()
205
+ const parentSavedAt = new Map<string, number>()
206
+
207
+ async function loadMessages(threadId: string) {
208
+ return stores.messages.loadThread(threadId)
209
+ }
210
+
211
+ // The child and its ancestors, nearest first. Each keeps its own stream.
212
+ function lineage(subagentRunId: string) {
213
+ const notes: Array<ChildNote> = []
214
+ let id: string | undefined = subagentRunId
215
+ for (let depth = 0; id !== undefined && depth < 64; depth++) {
216
+ const note = children.get(id)
217
+ if (!note) break
218
+ notes.push(note)
219
+ id = note.parentSubagentRunId
220
+ }
221
+ return notes
222
+ }
223
+
224
+ async function saveChild(subagentRunId: string) {
225
+ const note = children.get(subagentRunId)
226
+ if (!note) return
227
+ note.savedAt = Date.now()
228
+ const info: SubagentWireInfo = {
229
+ name: note.name,
230
+ status: note.status,
231
+ ...(note.parentSubagentRunId !== undefined && {
232
+ parentSubagentRunId: note.parentSubagentRunId,
233
+ }),
234
+ ...(note.parentToolCallId !== undefined && {
235
+ parentToolCallId: note.parentToolCallId,
236
+ }),
237
+ ...(note.interruptIds !== undefined && {
238
+ interruptIds: note.interruptIds,
239
+ }),
240
+ ...(note.error !== undefined && { error: note.error }),
241
+ ...(note.metadata !== undefined && { metadata: note.metadata }),
242
+ }
243
+ const transcript = convertMessagesToModelMessages(
244
+ withoutCards(note.processor.getMessages()),
245
+ )
246
+ const [first, ...rest] = transcript
247
+ const head: ModelMessage = first
248
+ ? { ...first, metadata: withSubagentInfo(first.metadata, info) }
249
+ : {
250
+ id: `child:${subagentRunId}`,
251
+ role: 'assistant',
252
+ content: '',
253
+ metadata: withSubagentInfo(undefined, { ...info, placeholder: true }),
254
+ }
255
+ await stores.messages.saveThread(childStoreId(subagentRunId), [
256
+ head,
257
+ ...rest,
258
+ ])
259
+ }
260
+
261
+ // The parent message for a routed run holds the children's text. It exists
262
+ // even before any child writes text, so a reload can show a waiting card.
263
+ async function saveParent(threadId: string, runId: string) {
264
+ const notes = [...children.values()].filter(
265
+ (note) =>
266
+ note.parentRunId === runId &&
267
+ note.parentSubagentRunId === undefined &&
268
+ note.parentToolCallId === undefined,
269
+ )
270
+ if (notes.length === 0) return
271
+ const content = notes
272
+ .map((note) => {
273
+ const text = childText(note.processor.getMessages())
274
+ return text === '' ? '' : `${note.name}:\n${text}`
275
+ })
276
+ .filter((block) => block !== '')
277
+ .join('\n\n')
278
+ const stored = await loadMessages(threadId)
279
+ // A resume updates the message of the run that started the child.
280
+ const index = stored.findIndex(
281
+ (message) => readModelRunId(message) === runId,
282
+ )
283
+ const host = stored[index]
284
+ if (host) {
285
+ const next = [...stored]
286
+ next[index] = { ...host, content }
287
+ await stores.messages.saveThread(threadId, next)
288
+ return
289
+ }
290
+ const assistant: ModelMessage = {
291
+ id: assistantId(runId),
292
+ role: 'assistant',
293
+ content,
294
+ metadata: { tanstack: { runId } },
295
+ }
296
+ await stores.messages.saveThread(threadId, [...stored, assistant])
297
+ }
298
+
299
+ async function startChild(
300
+ input: { threadId: string; runId: string },
301
+ chunk: Extract<StreamChunk, { type: 'SUBAGENT_STARTED' }>,
302
+ ) {
303
+ const id = chunk.subagentRunId
304
+ const record = await stores.runs?.createOrResume({
305
+ runId: id,
306
+ threadId: childStoreId(id),
307
+ startedAt: Date.now(),
308
+ parentRunId: chunk.parentSubagentRunId ?? input.runId,
309
+ subagentRunId: id,
310
+ name: chunk.name,
311
+ })
312
+ const existing = children.get(id)
313
+ if (existing) {
314
+ existing.threadId = input.threadId
315
+ existing.runId = input.runId
316
+ existing.status = 'running'
317
+ delete existing.interruptIds
318
+ if (chunk.metadata !== undefined) existing.metadata = chunk.metadata
319
+ } else {
320
+ // A resume continues the stored transcript.
321
+ const stored = (await loadMessages(childStoreId(id))).filter(
322
+ (message) => storedSubagentInfo([message])?.placeholder !== true,
323
+ )
324
+ children.set(id, {
325
+ name: chunk.name,
326
+ threadId: input.threadId,
327
+ runId: input.runId,
328
+ savedAt: 0,
329
+ parentRunId:
330
+ record?.parentRunId ?? chunk.parentSubagentRunId ?? input.runId,
331
+ ...(chunk.parentSubagentRunId !== undefined && {
332
+ parentSubagentRunId: chunk.parentSubagentRunId,
333
+ }),
334
+ ...(chunk.parentToolCallId !== undefined && {
335
+ parentToolCallId: chunk.parentToolCallId,
336
+ }),
337
+ ...(chunk.metadata !== undefined && { metadata: chunk.metadata }),
338
+ processor: new StreamProcessor({
339
+ subagentRunId: id,
340
+ initialMessages: modelMessagesToUIMessages(stored),
341
+ }),
342
+ status: 'running',
343
+ })
344
+ }
345
+ if (record && record.status !== 'running') {
346
+ await stores.runs?.update(id, { status: 'running' })
347
+ }
348
+ // The parent child shows this one as a nested card.
349
+ if (chunk.parentSubagentRunId !== undefined) {
350
+ children.get(chunk.parentSubagentRunId)?.processor.processChunk(chunk)
351
+ }
352
+ await saveChild(id)
353
+ }
354
+
355
+ async function settleChild(
356
+ chunk: Extract<
357
+ StreamChunk,
358
+ { type: 'SUBAGENT_FINISHED' | 'SUBAGENT_ERROR' }
359
+ >,
360
+ ) {
361
+ const id = chunk.subagentRunId
362
+ const note = children.get(id)
363
+ if (!note) return
364
+ try {
365
+ await writeSettled(note, id, chunk)
366
+ } finally {
367
+ // A routed child stays until its run ends: the parent message reads its
368
+ // text. Nested children and children started by a tool call go now.
369
+ if (
370
+ note.parentSubagentRunId !== undefined ||
371
+ note.parentToolCallId !== undefined
372
+ ) {
373
+ children.delete(id)
374
+ }
375
+ }
376
+ }
377
+
378
+ async function writeSettled(
379
+ note: ChildNote,
380
+ id: string,
381
+ chunk: Extract<
382
+ StreamChunk,
383
+ { type: 'SUBAGENT_FINISHED' | 'SUBAGENT_ERROR' }
384
+ >,
385
+ ) {
386
+ note.processor.finalizeStream()
387
+ if (note.parentSubagentRunId !== undefined) {
388
+ children.get(note.parentSubagentRunId)?.processor.processChunk(chunk)
389
+ }
390
+ if (chunk.type === 'SUBAGENT_ERROR') {
391
+ const stopped = chunk.message === 'Stopped'
392
+ note.status = 'error'
393
+ note.error = { message: chunk.message }
394
+ await saveChild(id)
395
+ await stores.runs?.update(id, {
396
+ status: stopped ? 'aborted' : 'failed',
397
+ finishedAt: Date.now(),
398
+ ...(!stopped ? { error: { message: chunk.message } } : {}),
399
+ })
400
+ return
401
+ }
402
+ if (chunk.outcome?.type === 'suspended') {
403
+ note.status = 'suspended'
404
+ note.interruptIds = chunk.outcome.interruptIds ?? []
405
+ await saveChild(id)
406
+ await stores.runs?.update(id, { status: 'interrupted' })
407
+ return
408
+ }
409
+ note.status = 'finished'
410
+ await saveChild(id)
411
+ await stores.runs?.update(id, {
412
+ status: 'completed',
413
+ finishedAt: Date.now(),
414
+ })
415
+ }
416
+
417
+ async function commitAnswers(runId: string) {
418
+ const entries = answered.get(runId) ?? []
419
+ answered.delete(runId)
420
+ for (const entry of entries) {
421
+ if (entry.status === 'cancelled') {
422
+ await stores.interrupts?.cancel(entry.interruptId)
423
+ } else {
424
+ await stores.interrupts?.resolve(entry.interruptId, entry.payload)
425
+ }
426
+ }
427
+ }
428
+
429
+ /** The notes a run fed. */
430
+ function notesOf(runId: string) {
431
+ return [...children].filter(([, note]) => note.runId === runId)
432
+ }
433
+
434
+ function forget(runId: string) {
435
+ for (const [id] of notesOf(runId)) children.delete(id)
436
+ parentSavedAt.delete(runId)
437
+ }
438
+
439
+ async function settleOpenChildren(
440
+ runId: string,
441
+ status: 'completed' | 'failed' | 'aborted',
442
+ error?: { message: string },
443
+ ) {
444
+ for (const [subagentRunId] of notesOf(runId)) {
445
+ await saveChild(subagentRunId)
446
+ const current = await stores.runs?.get(subagentRunId)
447
+ if (current && current.status !== 'running') continue
448
+ await stores.runs?.update(subagentRunId, {
449
+ status,
450
+ finishedAt: Date.now(),
451
+ ...(error ? { error } : {}),
452
+ })
453
+ }
454
+ }
455
+
456
+ /** Write the parent messages of this thread's routed children. */
457
+ async function saveParents(threadId: string) {
458
+ const runIds = new Set(
459
+ [...children.values()]
460
+ .filter((note) => note.threadId === threadId)
461
+ .map((note) => note.parentRunId),
462
+ )
463
+ for (const runId of runIds) await saveParent(threadId, runId)
464
+ }
465
+
466
+ return {
467
+ async start(input: {
468
+ threadId: string
469
+ runId: string
470
+ messages: ReadonlyArray<UIMessage | ModelMessage>
471
+ resume?: ReadonlyArray<RunAgentResumeItem>
472
+ }) {
473
+ await stores.runs?.createOrResume({
474
+ runId: input.runId,
475
+ threadId: input.threadId,
476
+ startedAt: Date.now(),
477
+ })
478
+ if (input.resume?.length) answered.set(input.runId, [...input.resume])
479
+ const incoming = convertMessagesToModelMessages([...input.messages])
480
+ const stored = await loadMessages(input.threadId)
481
+ const merged = mergeStoredMessages(stored, incoming)
482
+ await stores.messages.saveThread(
483
+ input.threadId,
484
+ keepSubagentRunIds(stored, merged),
485
+ )
486
+ },
487
+
488
+ async chunk(input: {
489
+ threadId: string
490
+ runId: string
491
+ chunk: StreamChunk
492
+ }) {
493
+ const chunk = input.chunk
494
+ if (chunk.type === 'SUBAGENT_STARTED') {
495
+ await startChild(input, chunk)
496
+ return
497
+ }
498
+ if (
499
+ chunk.type === 'SUBAGENT_FINISHED' ||
500
+ chunk.type === 'SUBAGENT_ERROR'
501
+ ) {
502
+ await settleChild(chunk)
503
+ await saveParents(input.threadId)
504
+ return
505
+ }
506
+ const subagentRunId = readSubagentRunId(chunk)
507
+ if (!subagentRunId) return
508
+ const notes = lineage(subagentRunId)
509
+ if (notes.length === 0) return
510
+ for (const note of notes) note.processor.processChunk(chunk)
511
+ // A streaming child writes at most once per interval. Its terminal
512
+ // event and the run's end always write.
513
+ const now = Date.now()
514
+ const note = notes[0]
515
+ if (note && now - note.savedAt >= intervalMs) {
516
+ await saveChild(subagentRunId)
517
+ }
518
+ if (
519
+ chunk.type === 'TEXT_MESSAGE_CONTENT' &&
520
+ now - (parentSavedAt.get(input.runId) ?? 0) >= intervalMs
521
+ ) {
522
+ parentSavedAt.set(input.runId, now)
523
+ await saveParents(input.threadId)
524
+ }
525
+ },
526
+
527
+ async suspend(input: {
528
+ threadId: string
529
+ runId: string
530
+ interrupts: ReadonlyArray<Interrupt>
531
+ }) {
532
+ await commitAnswers(input.runId)
533
+ await saveParents(input.threadId)
534
+ for (const [subagentRunId] of notesOf(input.runId)) {
535
+ await saveChild(subagentRunId)
536
+ }
537
+ for (const interrupt of input.interrupts) {
538
+ await stores.interrupts?.create({
539
+ interruptId: interrupt.id,
540
+ runId: input.runId,
541
+ threadId: input.threadId,
542
+ requestedAt: Date.now(),
543
+ payload: { ...interrupt },
544
+ })
545
+ }
546
+ await stores.runs?.update(input.runId, { status: 'interrupted' })
547
+ forget(input.runId)
548
+ },
549
+
550
+ async finish(input: { threadId: string; runId: string }) {
551
+ await commitAnswers(input.runId)
552
+ await saveParents(input.threadId)
553
+ await settleOpenChildren(input.runId, 'completed')
554
+ await stores.runs?.update(input.runId, {
555
+ status: 'completed',
556
+ finishedAt: Date.now(),
557
+ })
558
+ forget(input.runId)
559
+ },
560
+
561
+ async abort(input: { threadId: string; runId: string; error?: unknown }) {
562
+ const aborted =
563
+ input.error instanceof Error && input.error.name === 'AbortError'
564
+ const message =
565
+ input.error instanceof Error ? input.error.message : 'Run failed'
566
+ await settleOpenChildren(
567
+ input.runId,
568
+ aborted ? 'aborted' : 'failed',
569
+ aborted ? undefined : { message },
570
+ )
571
+ await saveParents(input.threadId)
572
+ await stores.runs?.update(input.runId, {
573
+ status: aborted ? 'aborted' : 'failed',
574
+ finishedAt: Date.now(),
575
+ ...(!aborted ? { error: { message } } : {}),
576
+ })
577
+ answered.delete(input.runId)
578
+ forget(input.runId)
579
+ },
580
+ }
581
+ }