@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.
- 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,67 @@
|
|
|
1
|
+
import type { ModelMessage } from '@tanstack/ai'
|
|
2
|
+
|
|
3
|
+
// Empty incoming keeps stored. Non-empty: the last incoming id that already
|
|
4
|
+
// exists in stored is a cutoff (reload drops the old assistant after that
|
|
5
|
+
// user). Same id is replaced in place. New ids and messages with no id are
|
|
6
|
+
// appended.
|
|
7
|
+
export function mergeStoredMessages(
|
|
8
|
+
stored: ReadonlyArray<ModelMessage>,
|
|
9
|
+
incoming: ReadonlyArray<ModelMessage>,
|
|
10
|
+
) {
|
|
11
|
+
if (incoming.length === 0) {
|
|
12
|
+
return stored.slice()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let cutoff = stored.length
|
|
16
|
+
for (let index = incoming.length - 1; index >= 0; index--) {
|
|
17
|
+
const id = incoming[index]?.id
|
|
18
|
+
if (id === undefined) continue
|
|
19
|
+
const storedIndex = stored.findIndex((message) => message.id === id)
|
|
20
|
+
if (storedIndex >= 0) {
|
|
21
|
+
cutoff = storedIndex + 1
|
|
22
|
+
break
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const prefix = stored.slice(0, cutoff)
|
|
26
|
+
|
|
27
|
+
const incomingById = new Map<string, ModelMessage>()
|
|
28
|
+
for (const message of incoming) {
|
|
29
|
+
const id = message.id
|
|
30
|
+
if (id) incomingById.set(id, message)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const storedIds = new Set<string>()
|
|
34
|
+
const merged: Array<ModelMessage> = []
|
|
35
|
+
for (const message of prefix) {
|
|
36
|
+
const id = message.id
|
|
37
|
+
if (id) {
|
|
38
|
+
storedIds.add(id)
|
|
39
|
+
merged.push(incomingById.get(id) ?? message)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
merged.push(message)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (let index = 0; index < incoming.length; index++) {
|
|
46
|
+
const message = incoming[index]
|
|
47
|
+
if (!message) continue
|
|
48
|
+
const id = message.id
|
|
49
|
+
if (id && storedIds.has(id)) continue
|
|
50
|
+
// Attach/reload can post the stored transcript again with no ids. Keep the
|
|
51
|
+
// prefix row instead of appending a second copy of the same turn.
|
|
52
|
+
if (!id) {
|
|
53
|
+
const existing = merged[index]
|
|
54
|
+
if (
|
|
55
|
+
existing &&
|
|
56
|
+
existing.id === undefined &&
|
|
57
|
+
existing.role === message.role &&
|
|
58
|
+
existing.content === message.content
|
|
59
|
+
) {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
merged.push(message)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return merged
|
|
67
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
providePersistence,
|
|
30
30
|
providePersistenceCompletion,
|
|
31
31
|
} from './capabilities'
|
|
32
|
+
import { mergeStoredMessages } from './merge-stored'
|
|
33
|
+
import { createSubagentRunRecorder } from './subagent-runs'
|
|
32
34
|
import {
|
|
33
35
|
validateChatPersistenceStores,
|
|
34
36
|
validateGenerationPersistenceStores,
|
|
@@ -1915,72 +1917,6 @@ function threadMessages(
|
|
|
1915
1917
|
return Array.isArray(loaded) ? loaded : loaded.messages
|
|
1916
1918
|
}
|
|
1917
1919
|
|
|
1918
|
-
// Empty incoming keeps stored. Non-empty: the last incoming id that already
|
|
1919
|
-
// exists in stored is a cutoff (reload drops the old assistant after that
|
|
1920
|
-
// user). Same id is replaced in place. New ids and messages with no id are
|
|
1921
|
-
// appended.
|
|
1922
|
-
function mergeStoredMessages(
|
|
1923
|
-
stored: ReadonlyArray<ModelMessage>,
|
|
1924
|
-
incoming: ReadonlyArray<ModelMessage>,
|
|
1925
|
-
) {
|
|
1926
|
-
if (incoming.length === 0) {
|
|
1927
|
-
return stored.slice()
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
let cutoff = stored.length
|
|
1931
|
-
for (let index = incoming.length - 1; index >= 0; index--) {
|
|
1932
|
-
const id = incoming[index]?.id
|
|
1933
|
-
if (id === undefined) continue
|
|
1934
|
-
const storedIndex = stored.findIndex((message) => message.id === id)
|
|
1935
|
-
if (storedIndex >= 0) {
|
|
1936
|
-
cutoff = storedIndex + 1
|
|
1937
|
-
break
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
const prefix = stored.slice(0, cutoff)
|
|
1941
|
-
|
|
1942
|
-
const incomingById = new Map<string, ModelMessage>()
|
|
1943
|
-
for (const message of incoming) {
|
|
1944
|
-
const id = message.id
|
|
1945
|
-
if (id) incomingById.set(id, message)
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
const storedIds = new Set<string>()
|
|
1949
|
-
const merged: Array<ModelMessage> = []
|
|
1950
|
-
for (const message of prefix) {
|
|
1951
|
-
const id = message.id
|
|
1952
|
-
if (id) {
|
|
1953
|
-
storedIds.add(id)
|
|
1954
|
-
merged.push(incomingById.get(id) ?? message)
|
|
1955
|
-
continue
|
|
1956
|
-
}
|
|
1957
|
-
merged.push(message)
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
for (let index = 0; index < incoming.length; index++) {
|
|
1961
|
-
const message = incoming[index]
|
|
1962
|
-
if (!message) continue
|
|
1963
|
-
const id = message.id
|
|
1964
|
-
if (id && storedIds.has(id)) continue
|
|
1965
|
-
// Attach/reload can post the stored transcript again with no ids. Keep the
|
|
1966
|
-
// prefix row instead of appending a second copy of the same turn.
|
|
1967
|
-
if (!id) {
|
|
1968
|
-
const existing = merged[index]
|
|
1969
|
-
if (
|
|
1970
|
-
existing &&
|
|
1971
|
-
existing.id === undefined &&
|
|
1972
|
-
existing.role === message.role &&
|
|
1973
|
-
existing.content === message.content
|
|
1974
|
-
) {
|
|
1975
|
-
continue
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
merged.push(message)
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
return merged
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
1920
|
export interface WithPersistenceOptions {
|
|
1985
1921
|
/**
|
|
1986
1922
|
* Also persist a throttled snapshot of the in-progress assistant reply while
|
|
@@ -1993,7 +1929,8 @@ export interface WithPersistenceOptions {
|
|
|
1993
1929
|
snapshotStreaming?: boolean
|
|
1994
1930
|
/**
|
|
1995
1931
|
* Minimum milliseconds between streaming snapshots when `snapshotStreaming`
|
|
1996
|
-
* is on. Defaults to 1000.
|
|
1932
|
+
* is on. Defaults to 1000. A streaming subagent child always uses the same
|
|
1933
|
+
* interval for its transcript writes, with or without `snapshotStreaming`.
|
|
1997
1934
|
*/
|
|
1998
1935
|
snapshotIntervalMs?: number
|
|
1999
1936
|
}
|
|
@@ -2044,8 +1981,18 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
2044
1981
|
...(wantsInterrupts ? [InterruptsCapability] : []),
|
|
2045
1982
|
]
|
|
2046
1983
|
|
|
1984
|
+
const subagentRuns = createSubagentRunRecorder({
|
|
1985
|
+
messages: messageStore,
|
|
1986
|
+
runs,
|
|
1987
|
+
intervalMs: snapshotIntervalMs,
|
|
1988
|
+
...(wantsInterrupts && persistence.stores.interrupts
|
|
1989
|
+
? { interrupts: persistence.stores.interrupts }
|
|
1990
|
+
: {}),
|
|
1991
|
+
})
|
|
1992
|
+
|
|
2047
1993
|
return defineChatMiddleware({
|
|
2048
1994
|
name: 'chat-persistence',
|
|
1995
|
+
routedSubagentPersistence: subagentRuns,
|
|
2049
1996
|
provides,
|
|
2050
1997
|
setup(ctx: ChatMiddlewareContext) {
|
|
2051
1998
|
providePersistence(ctx, persistence)
|
|
@@ -2183,6 +2130,11 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
2183
2130
|
},
|
|
2184
2131
|
|
|
2185
2132
|
async onChunk(ctx: ChatMiddlewareContext, chunk: StreamChunk) {
|
|
2133
|
+
await subagentRuns.chunk({
|
|
2134
|
+
threadId: ctx.threadId,
|
|
2135
|
+
runId: ctx.runId,
|
|
2136
|
+
chunk,
|
|
2137
|
+
})
|
|
2186
2138
|
// Capture the current assistant turn's identity for optional in-progress
|
|
2187
2139
|
// snapshots. Completed messages already live in `ctx.messages`.
|
|
2188
2140
|
if (snapshotStreaming && ctx.phase === 'modelStream') {
|
package/src/reconstruct.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { modelMessagesToUIMessages } from '@tanstack/ai'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ModelMessage,
|
|
4
|
+
RunRecord,
|
|
5
|
+
SubagentPart,
|
|
6
|
+
UIMessage,
|
|
7
|
+
} from '@tanstack/ai'
|
|
8
|
+
import { storedSubagentInfo } from './subagent-runs'
|
|
3
9
|
import { validateReconstructChatStores } from './types'
|
|
4
10
|
import type {
|
|
5
11
|
AIPersistence,
|
|
6
12
|
ChatTranscriptStores,
|
|
13
|
+
InterruptRecord,
|
|
7
14
|
MessagePage,
|
|
8
15
|
MessageStore,
|
|
9
16
|
} from './types'
|
|
@@ -171,8 +178,15 @@ export async function reconstructChat(
|
|
|
171
178
|
before,
|
|
172
179
|
})
|
|
173
180
|
: windowFromMessagePage(stored, pageSize)
|
|
181
|
+
const messages = await attachSubagentCards(
|
|
182
|
+
transcript.messages,
|
|
183
|
+
persistence.stores.runs,
|
|
184
|
+
messageStore,
|
|
185
|
+
threadId,
|
|
186
|
+
pending,
|
|
187
|
+
)
|
|
174
188
|
const body: ReconstructedChat = {
|
|
175
|
-
messages
|
|
189
|
+
messages,
|
|
176
190
|
activeRun: active ? { runId: active.runId } : null,
|
|
177
191
|
interrupts: firstPending
|
|
178
192
|
? {
|
|
@@ -190,6 +204,145 @@ export async function reconstructChat(
|
|
|
190
204
|
})
|
|
191
205
|
}
|
|
192
206
|
|
|
207
|
+
function messageRunId(message: UIMessage) {
|
|
208
|
+
const metadata = message.metadata
|
|
209
|
+
if (!metadata || typeof metadata !== 'object') return
|
|
210
|
+
const tanstack = metadata.tanstack
|
|
211
|
+
if (!tanstack || typeof tanstack !== 'object') return
|
|
212
|
+
const runId = (tanstack as { runId?: unknown }).runId
|
|
213
|
+
return typeof runId === 'string' && runId !== '' ? runId : undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
type Runs = NonNullable<ChatTranscriptStores['runs']>
|
|
217
|
+
|
|
218
|
+
/** Rebuild one child card from its run record and stored transcript. */
|
|
219
|
+
async function childCard(
|
|
220
|
+
child: RunRecord,
|
|
221
|
+
runs: Runs,
|
|
222
|
+
messageStore: MessageStore,
|
|
223
|
+
pending: ReadonlyArray<InterruptRecord>,
|
|
224
|
+
depth: number,
|
|
225
|
+
): Promise<SubagentPart> {
|
|
226
|
+
const subagentRunId = child.subagentRunId ?? child.runId
|
|
227
|
+
const stored = await messageStore.loadThread(child.threadId)
|
|
228
|
+
const info = storedSubagentInfo(stored)
|
|
229
|
+
const messages = modelMessagesToUIMessages(
|
|
230
|
+
stored.filter(
|
|
231
|
+
(message) => storedSubagentInfo([message])?.placeholder !== true,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
const nested =
|
|
235
|
+
runs.listByParentRun && depth < 8
|
|
236
|
+
? await runs.listByParentRun(subagentRunId)
|
|
237
|
+
: []
|
|
238
|
+
if (nested.length > 0) {
|
|
239
|
+
const cards = await Promise.all(
|
|
240
|
+
nested.map((run) =>
|
|
241
|
+
childCard(run, runs, messageStore, pending, depth + 1),
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
const last = messages.findLastIndex((m) => m.role === 'assistant')
|
|
245
|
+
if (last === -1) {
|
|
246
|
+
messages.push({
|
|
247
|
+
id: `child-cards:${subagentRunId}`,
|
|
248
|
+
role: 'assistant',
|
|
249
|
+
parts: cards,
|
|
250
|
+
})
|
|
251
|
+
} else {
|
|
252
|
+
const host = messages[last]
|
|
253
|
+
if (host) messages[last] = { ...host, parts: [...host.parts, ...cards] }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const failed = child.status === 'failed' || child.status === 'aborted'
|
|
257
|
+
const interruptIds = pending
|
|
258
|
+
.filter((record) => record.payload.subagentRunId === subagentRunId)
|
|
259
|
+
.map((record) => record.interruptId)
|
|
260
|
+
return {
|
|
261
|
+
type: 'subagent',
|
|
262
|
+
subagent: {
|
|
263
|
+
id: subagentRunId,
|
|
264
|
+
name: child.name ?? info?.name ?? 'subagent',
|
|
265
|
+
status: failed
|
|
266
|
+
? 'error'
|
|
267
|
+
: child.status === 'running'
|
|
268
|
+
? 'running'
|
|
269
|
+
: child.status === 'interrupted'
|
|
270
|
+
? 'suspended'
|
|
271
|
+
: 'finished',
|
|
272
|
+
...(child.parentRunId !== undefined && {
|
|
273
|
+
parentRunId: child.parentRunId,
|
|
274
|
+
}),
|
|
275
|
+
...(info?.parentToolCallId !== undefined && {
|
|
276
|
+
parentToolCallId: info.parentToolCallId,
|
|
277
|
+
}),
|
|
278
|
+
...(interruptIds.length > 0 && { interruptIds }),
|
|
279
|
+
...(info?.metadata !== undefined && { metadata: info.metadata }),
|
|
280
|
+
messages,
|
|
281
|
+
...(failed && child.error ? { error: child.error } : {}),
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Put stored subagent cards back on the transcript. A routed child sits on
|
|
288
|
+
* the parent assistant message of its run. A child that a tool call started
|
|
289
|
+
* sits on the message that holds that tool call.
|
|
290
|
+
*/
|
|
291
|
+
async function attachSubagentCards(
|
|
292
|
+
messages: Array<UIMessage>,
|
|
293
|
+
runs: ChatTranscriptStores['runs'],
|
|
294
|
+
messageStore: MessageStore,
|
|
295
|
+
threadId: string,
|
|
296
|
+
pending: ReadonlyArray<InterruptRecord>,
|
|
297
|
+
) {
|
|
298
|
+
if (!runs?.listByParentRun) return messages
|
|
299
|
+
const parentRunIds = new Set<string>()
|
|
300
|
+
for (const message of messages) {
|
|
301
|
+
const runId = messageRunId(message)
|
|
302
|
+
if (runId) parentRunIds.add(runId)
|
|
303
|
+
}
|
|
304
|
+
const hasToolCalls = messages.some((message) =>
|
|
305
|
+
message.parts.some((part) => part.type === 'tool-call'),
|
|
306
|
+
)
|
|
307
|
+
if (hasToolCalls && runs.listByThread && threadId !== '') {
|
|
308
|
+
for (const run of await runs.listByThread(threadId)) {
|
|
309
|
+
parentRunIds.add(run.runId)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const cardsByRun = new Map<string, Array<SubagentPart>>()
|
|
314
|
+
const cardsByToolCall = new Map<string, Array<SubagentPart>>()
|
|
315
|
+
for (const runId of parentRunIds) {
|
|
316
|
+
for (const child of await runs.listByParentRun(runId)) {
|
|
317
|
+
const card = await childCard(child, runs, messageStore, pending, 0)
|
|
318
|
+
const toolCallId = card.subagent.parentToolCallId
|
|
319
|
+
const target = toolCallId === undefined ? cardsByRun : cardsByToolCall
|
|
320
|
+
const key = toolCallId ?? runId
|
|
321
|
+
target.set(key, [...(target.get(key) ?? []), card])
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (cardsByRun.size === 0 && cardsByToolCall.size === 0) return messages
|
|
325
|
+
|
|
326
|
+
return messages.map((message) => {
|
|
327
|
+
if (message.role !== 'assistant') return message
|
|
328
|
+
const runId = messageRunId(message)
|
|
329
|
+
const routed = runId !== undefined ? cardsByRun.get(runId) : undefined
|
|
330
|
+
const started = message.parts.flatMap((part) =>
|
|
331
|
+
part.type === 'tool-call' ? (cardsByToolCall.get(part.id) ?? []) : [],
|
|
332
|
+
)
|
|
333
|
+
if (!routed && started.length === 0) return message
|
|
334
|
+
// A routed parent message holds only the children's text. The cards
|
|
335
|
+
// replace it.
|
|
336
|
+
const parts = routed
|
|
337
|
+
? message.parts.filter((part) => part.type !== 'text')
|
|
338
|
+
: message.parts
|
|
339
|
+
return {
|
|
340
|
+
...message,
|
|
341
|
+
parts: [...(routed ?? []), ...parts, ...started],
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
193
346
|
function parsePageSize(raw: string | null) {
|
|
194
347
|
if (raw == null) return
|
|
195
348
|
const pageSize = Number(raw)
|