@tanstack/ai-persistence 0.6.4 → 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.
- package/dist/esm/memory.js +8 -1
- package/dist/esm/memory.js.map +1 -1
- package/dist/esm/merge-stored.d.ts +2 -0
- package/dist/esm/merge-stored.js +47 -0
- package/dist/esm/merge-stored.js.map +1 -0
- package/dist/esm/middleware.d.ts +2 -1
- package/dist/esm/middleware.js +18 -44
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/reconstruct.js +90 -1
- package/dist/esm/reconstruct.js.map +1 -1
- package/dist/esm/subagent-runs.d.ts +40 -0
- package/dist/esm/subagent-runs.js +366 -0
- package/dist/esm/subagent-runs.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +4 -1
- package/dist/esm/testkit/conformance.js +94 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/package.json +3 -3
- package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +41 -16
- package/skills/ai-persistence/build-custom-adapter/SKILL.md +49 -20
- package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +54 -9
- package/skills/ai-persistence/build-prisma-adapter/SKILL.md +32 -6
- package/skills/ai-persistence/stores/SKILL.md +46 -29
- package/src/memory.ts +16 -0
- package/src/merge-stored.ts +67 -0
- package/src/middleware.ts +19 -67
- package/src/reconstruct.ts +155 -2
- package/src/subagent-runs.ts +581 -0
- package/src/testkit/conformance.ts +125 -2
|
@@ -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
|
+
}
|