@tanstack/ai-persistence 0.2.0 → 0.4.1
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/capabilities.d.ts +6 -0
- package/dist/esm/capabilities.js +3 -1
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/memory.js +41 -1
- package/dist/esm/memory.js.map +1 -1
- package/dist/esm/middleware.js +296 -54
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/testkit/conformance.js +63 -10
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/types.d.ts +35 -1
- package/dist/esm/types.js.map +1 -1
- package/package.json +3 -3
- package/skills/ai-persistence/SKILL.md +7 -0
- package/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +47 -25
- package/skills/ai-persistence/server/SKILL.md +17 -10
- package/src/capabilities.ts +10 -0
- package/src/index.ts +5 -0
- package/src/memory.ts +78 -1
- package/src/middleware.ts +543 -88
- package/src/testkit/conformance.ts +77 -12
- package/src/types.ts +38 -1
package/src/middleware.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import {
|
|
2
2
|
defineChatMiddleware,
|
|
3
3
|
getDetachableRun,
|
|
4
|
+
InterruptResumeValidationError,
|
|
5
|
+
readInterruptBinding,
|
|
6
|
+
validateInterruptResumeBatch,
|
|
4
7
|
wasCancelRequested,
|
|
5
8
|
} from '@tanstack/ai'
|
|
6
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createInterruptBinding,
|
|
11
|
+
getGenericInterruptDefinitionRegistry,
|
|
12
|
+
providePendingTurn,
|
|
13
|
+
rehydrateInterruptRequest,
|
|
14
|
+
toRunErrorPayload,
|
|
15
|
+
} from '@tanstack/ai/adapter-internals'
|
|
16
|
+
import type {
|
|
17
|
+
GenericInterruptRequest,
|
|
18
|
+
InterruptDefinition,
|
|
19
|
+
} from '@tanstack/ai/adapter-internals'
|
|
7
20
|
import { base64ToUint8Array } from '@tanstack/ai-utils'
|
|
8
21
|
import {
|
|
9
22
|
InterruptsCapability,
|
|
10
23
|
PersistenceCapability,
|
|
24
|
+
PersistenceCompletionCapability,
|
|
11
25
|
provideInterrupts,
|
|
12
26
|
providePersistence,
|
|
27
|
+
providePersistenceCompletion,
|
|
13
28
|
} from './capabilities'
|
|
14
29
|
import {
|
|
15
30
|
validateChatPersistenceStores,
|
|
@@ -28,12 +43,14 @@ import type {
|
|
|
28
43
|
GenerationFinishInfo,
|
|
29
44
|
GenerationMiddleware,
|
|
30
45
|
GenerationMiddlewareContext,
|
|
31
|
-
|
|
46
|
+
Interrupt,
|
|
47
|
+
PendingInterruptResumeRecord,
|
|
32
48
|
PersistedArtifactActivity,
|
|
33
49
|
PersistedArtifactRef,
|
|
34
50
|
PersistedArtifactRole,
|
|
35
51
|
RunAgentResumeItem,
|
|
36
52
|
StreamChunk,
|
|
53
|
+
Tool,
|
|
37
54
|
ToolApprovalResolution,
|
|
38
55
|
BilledUsage,
|
|
39
56
|
TokenUsage,
|
|
@@ -44,6 +61,7 @@ import type {
|
|
|
44
61
|
ArtifactRecord,
|
|
45
62
|
BlobBody,
|
|
46
63
|
ChatTranscriptStores,
|
|
64
|
+
InterruptCommitEntry,
|
|
47
65
|
InterruptRecord,
|
|
48
66
|
RunStore,
|
|
49
67
|
} from './types'
|
|
@@ -269,33 +287,153 @@ interface RunStateEntry {
|
|
|
269
287
|
*/
|
|
270
288
|
streamingMessageId?: string
|
|
271
289
|
streamingMessageCreatedAt?: Date
|
|
290
|
+
completion?: {
|
|
291
|
+
promise: Promise<void>
|
|
292
|
+
resolve: () => void
|
|
293
|
+
reject: (error: unknown) => void
|
|
294
|
+
}
|
|
272
295
|
}
|
|
273
296
|
|
|
274
297
|
const runState = new WeakMap<object, RunStateEntry>()
|
|
275
298
|
|
|
276
299
|
const validResumeStatuses = new Set(['resolved', 'cancelled'])
|
|
277
300
|
|
|
301
|
+
function mergeMaps<K, V>(
|
|
302
|
+
left?: ReadonlyMap<K, V>,
|
|
303
|
+
right?: ReadonlyMap<K, V>,
|
|
304
|
+
): Map<K, V> | undefined {
|
|
305
|
+
if (!left && !right) return undefined
|
|
306
|
+
return new Map([...(left ?? []), ...(right ?? [])])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function mergeSets<T>(
|
|
310
|
+
left?: ReadonlySet<T>,
|
|
311
|
+
right?: ReadonlySet<T>,
|
|
312
|
+
): Set<T> | undefined {
|
|
313
|
+
if (!left && !right) return undefined
|
|
314
|
+
return new Set([...(left ?? []), ...(right ?? [])])
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function mergeResumeToolState(
|
|
318
|
+
left: ChatResumeToolState | undefined,
|
|
319
|
+
right: ChatResumeToolState | undefined,
|
|
320
|
+
): ChatResumeToolState | undefined {
|
|
321
|
+
if (!left) return right
|
|
322
|
+
if (!right) return left
|
|
323
|
+
return {
|
|
324
|
+
approvals: mergeMaps(left.approvals, right.approvals),
|
|
325
|
+
clientToolResults: mergeMaps(
|
|
326
|
+
left.clientToolResults,
|
|
327
|
+
right.clientToolResults,
|
|
328
|
+
),
|
|
329
|
+
genericInterrupts: mergeMaps(
|
|
330
|
+
left.genericInterrupts,
|
|
331
|
+
right.genericInterrupts,
|
|
332
|
+
),
|
|
333
|
+
genericInterruptRequests: mergeMaps(
|
|
334
|
+
left.genericInterruptRequests,
|
|
335
|
+
right.genericInterruptRequests,
|
|
336
|
+
),
|
|
337
|
+
deniedToolResults: mergeMaps(
|
|
338
|
+
left.deniedToolResults,
|
|
339
|
+
right.deniedToolResults,
|
|
340
|
+
),
|
|
341
|
+
cancelledToolCallIds: mergeSets(
|
|
342
|
+
left.cancelledToolCallIds,
|
|
343
|
+
right.cancelledToolCallIds,
|
|
344
|
+
),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function rejectMixedRunPending(
|
|
349
|
+
pending: Array<InterruptRecord>,
|
|
350
|
+
ctx: Pick<ChatMiddlewareContext, 'threadId' | 'runId'>,
|
|
351
|
+
): void {
|
|
352
|
+
const runIds = new Set(pending.map((interrupt) => interrupt.runId))
|
|
353
|
+
if (runIds.size <= 1) return
|
|
354
|
+
throw new InterruptResumeValidationError([
|
|
355
|
+
{
|
|
356
|
+
scope: 'batch',
|
|
357
|
+
threadId: ctx.threadId,
|
|
358
|
+
interruptedRunId: ctx.runId,
|
|
359
|
+
generation: 0,
|
|
360
|
+
interruptIds: pending.map((interrupt) => interrupt.interruptId),
|
|
361
|
+
code: 'stale',
|
|
362
|
+
message: 'Thread has pending interrupts from more than one run.',
|
|
363
|
+
source: 'server',
|
|
364
|
+
retryable: false,
|
|
365
|
+
},
|
|
366
|
+
])
|
|
367
|
+
}
|
|
368
|
+
|
|
278
369
|
function validatePendingResumes(
|
|
279
370
|
pending: Array<InterruptRecord>,
|
|
280
371
|
resume: Array<RunAgentResumeItem> | undefined,
|
|
372
|
+
ctx: Pick<ChatMiddlewareContext, 'threadId' | 'runId'>,
|
|
281
373
|
): Map<string, RunAgentResumeItem> {
|
|
374
|
+
const interruptedRunId = pending[0]?.runId ?? ctx.runId
|
|
375
|
+
const failure = (
|
|
376
|
+
interruptId: string,
|
|
377
|
+
code: 'conflict' | 'unknown-interrupt',
|
|
378
|
+
message: string,
|
|
379
|
+
): never => {
|
|
380
|
+
throw new InterruptResumeValidationError([
|
|
381
|
+
{
|
|
382
|
+
scope: 'item',
|
|
383
|
+
threadId: ctx.threadId,
|
|
384
|
+
interruptedRunId,
|
|
385
|
+
generation: 0,
|
|
386
|
+
interruptId,
|
|
387
|
+
code,
|
|
388
|
+
message,
|
|
389
|
+
source: 'client',
|
|
390
|
+
retryable: false,
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
scope: 'batch',
|
|
394
|
+
threadId: ctx.threadId,
|
|
395
|
+
interruptedRunId,
|
|
396
|
+
generation: 0,
|
|
397
|
+
interruptIds: pending.map((interrupt) => interrupt.interruptId),
|
|
398
|
+
code: code === 'conflict' ? 'conflict' : 'incomplete-batch',
|
|
399
|
+
message:
|
|
400
|
+
'Resume entries must resolve or cancel the complete interrupt batch.',
|
|
401
|
+
source: 'client',
|
|
402
|
+
retryable: false,
|
|
403
|
+
},
|
|
404
|
+
])
|
|
405
|
+
}
|
|
282
406
|
const pendingInterruptIds = new Set(
|
|
283
407
|
pending.map((interrupt) => interrupt.interruptId),
|
|
284
408
|
)
|
|
285
|
-
const resumeByInterruptId = new Map(
|
|
286
|
-
|
|
287
|
-
|
|
409
|
+
const resumeByInterruptId = new Map<string, RunAgentResumeItem>()
|
|
410
|
+
for (const entry of resume ?? []) {
|
|
411
|
+
if (resumeByInterruptId.has(entry.interruptId)) {
|
|
412
|
+
return failure(
|
|
413
|
+
entry.interruptId,
|
|
414
|
+
'conflict',
|
|
415
|
+
`Interrupt ${entry.interruptId} has duplicate resume entries.`,
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
resumeByInterruptId.set(entry.interruptId, entry)
|
|
419
|
+
}
|
|
288
420
|
if (pending.length === 0) {
|
|
289
421
|
const staleEntry = resume?.[0]
|
|
290
422
|
if (staleEntry) {
|
|
291
|
-
|
|
423
|
+
return failure(
|
|
424
|
+
staleEntry.interruptId,
|
|
425
|
+
'unknown-interrupt',
|
|
292
426
|
`Resume entry references non-pending interrupt ${staleEntry.interruptId}.`,
|
|
293
427
|
)
|
|
294
428
|
}
|
|
295
429
|
return resumeByInterruptId
|
|
296
430
|
}
|
|
431
|
+
const firstPending = pending[0]
|
|
432
|
+
if (firstPending === undefined) return resumeByInterruptId
|
|
297
433
|
if (!resume || resume.length === 0) {
|
|
298
|
-
|
|
434
|
+
return failure(
|
|
435
|
+
firstPending.interruptId,
|
|
436
|
+
'unknown-interrupt',
|
|
299
437
|
`Thread has pending interrupts; resume is required before accepting new input.`,
|
|
300
438
|
)
|
|
301
439
|
}
|
|
@@ -303,19 +441,25 @@ function validatePendingResumes(
|
|
|
303
441
|
for (const interrupt of pending) {
|
|
304
442
|
const entry = resumeByInterruptId.get(interrupt.interruptId)
|
|
305
443
|
if (!entry) {
|
|
306
|
-
|
|
444
|
+
return failure(
|
|
445
|
+
interrupt.interruptId,
|
|
446
|
+
'unknown-interrupt',
|
|
307
447
|
`Missing resume entry for pending interrupt ${interrupt.interruptId}.`,
|
|
308
448
|
)
|
|
309
449
|
}
|
|
310
450
|
if (!validResumeStatuses.has(entry.status)) {
|
|
311
|
-
|
|
451
|
+
return failure(
|
|
452
|
+
interrupt.interruptId,
|
|
453
|
+
'unknown-interrupt',
|
|
312
454
|
`Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`,
|
|
313
455
|
)
|
|
314
456
|
}
|
|
315
457
|
}
|
|
316
458
|
for (const entry of resume) {
|
|
317
459
|
if (!pendingInterruptIds.has(entry.interruptId)) {
|
|
318
|
-
|
|
460
|
+
return failure(
|
|
461
|
+
entry.interruptId,
|
|
462
|
+
'unknown-interrupt',
|
|
319
463
|
`Resume entry references non-pending interrupt ${entry.interruptId}.`,
|
|
320
464
|
)
|
|
321
465
|
}
|
|
@@ -328,13 +472,52 @@ async function applyPendingResumes(
|
|
|
328
472
|
resumeByInterruptId: Map<string, RunAgentResumeItem>,
|
|
329
473
|
interrupts: NonNullable<AIPersistence['stores']['interrupts']>,
|
|
330
474
|
): Promise<void> {
|
|
475
|
+
const entries: Array<InterruptCommitEntry> = []
|
|
331
476
|
for (const interrupt of pending) {
|
|
332
477
|
const entry = resumeByInterruptId.get(interrupt.interruptId)
|
|
333
478
|
if (!entry) continue
|
|
334
479
|
if (entry.status === 'resolved') {
|
|
335
|
-
|
|
480
|
+
entries.push({
|
|
481
|
+
interruptId: interrupt.interruptId,
|
|
482
|
+
status: 'resolved',
|
|
483
|
+
response: entry.payload,
|
|
484
|
+
})
|
|
485
|
+
} else {
|
|
486
|
+
entries.push({
|
|
487
|
+
interruptId: interrupt.interruptId,
|
|
488
|
+
status: 'cancelled',
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (interrupts.commitBatch) {
|
|
493
|
+
await interrupts.commitBatch(entries)
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
const ids = new Set<string>()
|
|
497
|
+
for (const entry of entries) {
|
|
498
|
+
if (ids.has(entry.interruptId)) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`Interrupt batch contains duplicate id: ${entry.interruptId}.`,
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
ids.add(entry.interruptId)
|
|
504
|
+
const existing = await interrupts.get(entry.interruptId)
|
|
505
|
+
if (!existing) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`Interrupt batch references missing id: ${entry.interruptId}.`,
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
if (existing.status !== 'pending') {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Interrupt batch references non-pending id: ${entry.interruptId}.`,
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const entry of entries) {
|
|
517
|
+
if (entry.status === 'resolved') {
|
|
518
|
+
await interrupts.resolve(entry.interruptId, entry.response)
|
|
336
519
|
} else {
|
|
337
|
-
await interrupts.cancel(
|
|
520
|
+
await interrupts.cancel(entry.interruptId)
|
|
338
521
|
}
|
|
339
522
|
}
|
|
340
523
|
}
|
|
@@ -378,6 +561,267 @@ function interruptKind(interrupt: InterruptRecord): string | undefined {
|
|
|
378
561
|
return metadata ? stringField(metadata, 'kind') : undefined
|
|
379
562
|
}
|
|
380
563
|
|
|
564
|
+
function hasReservedInterruptBinding(payload: unknown): boolean {
|
|
565
|
+
const descriptor = objectValue(payload)
|
|
566
|
+
const metadata = objectValue(descriptor?.metadata)
|
|
567
|
+
return !!metadata && 'tanstack:interruptBinding' in metadata
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isPersistedInterruptDescriptor(
|
|
571
|
+
value: unknown,
|
|
572
|
+
): value is Interrupt & { reason: string; message: string } {
|
|
573
|
+
const record = objectValue(value)
|
|
574
|
+
return (
|
|
575
|
+
!!record &&
|
|
576
|
+
typeof record.id === 'string' &&
|
|
577
|
+
typeof record.reason === 'string' &&
|
|
578
|
+
typeof record.message === 'string'
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Does this pending record belong to the TanStack chat resume protocol?
|
|
584
|
+
*
|
|
585
|
+
* An external system can persist an AG-UI descriptor in the same durable
|
|
586
|
+
* thread. A descriptor without a TanStack binding or legacy tool marker stays
|
|
587
|
+
* pending for its owner, but it does not make this resume incomplete. Older
|
|
588
|
+
* opaque records remain owned because their provenance cannot be known.
|
|
589
|
+
*/
|
|
590
|
+
function isChatOwnedPendingInterrupt(interrupt: InterruptRecord): boolean {
|
|
591
|
+
const kind = interruptKind(interrupt)
|
|
592
|
+
return (
|
|
593
|
+
!isPersistedInterruptDescriptor(interrupt.payload) ||
|
|
594
|
+
stringField(interrupt.payload, 'toolCallId') !== undefined ||
|
|
595
|
+
kind === 'approval' ||
|
|
596
|
+
kind === 'client_tool' ||
|
|
597
|
+
hasReservedInterruptBinding(interrupt.payload)
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function durableGenericFailure(
|
|
602
|
+
ctx: Pick<ChatMiddlewareContext, 'threadId' | 'runId'>,
|
|
603
|
+
persisted: InterruptRecord,
|
|
604
|
+
message: string,
|
|
605
|
+
): InterruptResumeValidationError {
|
|
606
|
+
return new InterruptResumeValidationError([
|
|
607
|
+
{
|
|
608
|
+
scope: 'item',
|
|
609
|
+
threadId: ctx.threadId,
|
|
610
|
+
interruptedRunId: persisted.runId || ctx.runId,
|
|
611
|
+
generation: 0,
|
|
612
|
+
interruptId: persisted.interruptId,
|
|
613
|
+
code: 'stale',
|
|
614
|
+
message,
|
|
615
|
+
source: 'server',
|
|
616
|
+
retryable: false,
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
scope: 'batch',
|
|
620
|
+
threadId: ctx.threadId,
|
|
621
|
+
interruptedRunId: persisted.runId || ctx.runId,
|
|
622
|
+
generation: 0,
|
|
623
|
+
interruptIds: [persisted.interruptId],
|
|
624
|
+
code: 'item-validation-failed',
|
|
625
|
+
message: 'One or more persisted interrupt records are invalid.',
|
|
626
|
+
source: 'server',
|
|
627
|
+
retryable: false,
|
|
628
|
+
},
|
|
629
|
+
])
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function durableGenericResumeState(
|
|
633
|
+
ctx: ChatMiddlewareContext,
|
|
634
|
+
pending: Array<InterruptRecord>,
|
|
635
|
+
resume: ReadonlyArray<RunAgentResumeItem>,
|
|
636
|
+
tools: Array<Tool>,
|
|
637
|
+
): Promise<ChatResumeToolState | undefined> {
|
|
638
|
+
const registry = getGenericInterruptDefinitionRegistry(ctx, {
|
|
639
|
+
optional: true,
|
|
640
|
+
})
|
|
641
|
+
const records: Array<PendingInterruptResumeRecord> = []
|
|
642
|
+
|
|
643
|
+
for (const persisted of pending) {
|
|
644
|
+
if (!isPersistedInterruptDescriptor(persisted.payload)) {
|
|
645
|
+
if (hasReservedInterruptBinding(persisted.payload)) {
|
|
646
|
+
throw durableGenericFailure(
|
|
647
|
+
ctx,
|
|
648
|
+
persisted,
|
|
649
|
+
`Persisted interrupt ${persisted.interruptId} has an invalid binding descriptor.`,
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
continue
|
|
653
|
+
}
|
|
654
|
+
const descriptor = persisted.payload
|
|
655
|
+
const binding = readInterruptBinding(descriptor)
|
|
656
|
+
if (!binding) {
|
|
657
|
+
if (hasReservedInterruptBinding(descriptor)) {
|
|
658
|
+
throw durableGenericFailure(
|
|
659
|
+
ctx,
|
|
660
|
+
persisted,
|
|
661
|
+
`Persisted interrupt ${persisted.interruptId} has an invalid or incomplete binding.`,
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
if (
|
|
667
|
+
descriptor.id !== persisted.interruptId ||
|
|
668
|
+
binding.interruptId !== persisted.interruptId ||
|
|
669
|
+
binding.interruptedRunId !== persisted.runId ||
|
|
670
|
+
binding.generation !== 0
|
|
671
|
+
) {
|
|
672
|
+
throw durableGenericFailure(
|
|
673
|
+
ctx,
|
|
674
|
+
persisted,
|
|
675
|
+
`Persisted interrupt ${persisted.interruptId} has stale correlation metadata.`,
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
if (binding.kind !== 'generic') {
|
|
679
|
+
records.push({
|
|
680
|
+
interruptId: persisted.interruptId,
|
|
681
|
+
payload: descriptor,
|
|
682
|
+
binding,
|
|
683
|
+
})
|
|
684
|
+
continue
|
|
685
|
+
}
|
|
686
|
+
if (
|
|
687
|
+
!binding.definitionId ||
|
|
688
|
+
!binding.key ||
|
|
689
|
+
binding.batchIndex === undefined
|
|
690
|
+
) {
|
|
691
|
+
records.push({
|
|
692
|
+
interruptId: persisted.interruptId,
|
|
693
|
+
payload: descriptor,
|
|
694
|
+
binding,
|
|
695
|
+
})
|
|
696
|
+
continue
|
|
697
|
+
}
|
|
698
|
+
if (!registry) {
|
|
699
|
+
throw durableGenericFailure(
|
|
700
|
+
ctx,
|
|
701
|
+
persisted,
|
|
702
|
+
`Persisted generic interrupt ${persisted.interruptId} cannot be restored because no interrupt registry is available.`,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
const definition = registry.definitions.get(binding.definitionId)
|
|
706
|
+
if (!definition) {
|
|
707
|
+
throw durableGenericFailure(
|
|
708
|
+
ctx,
|
|
709
|
+
persisted,
|
|
710
|
+
`Persisted generic interrupt definition ${binding.definitionId} is unavailable.`,
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
const metadata = objectValue(descriptor.metadata)
|
|
714
|
+
const payload = metadata?.['tanstack:interruptPayload']
|
|
715
|
+
let request: GenericInterruptRequest<
|
|
716
|
+
InterruptDefinition<any, any, any, any>
|
|
717
|
+
>
|
|
718
|
+
try {
|
|
719
|
+
request = rehydrateInterruptRequest(definition, {
|
|
720
|
+
key: binding.key,
|
|
721
|
+
reason: descriptor.reason,
|
|
722
|
+
message: descriptor.message,
|
|
723
|
+
...(descriptor.expiresAt !== undefined
|
|
724
|
+
? { expiresAt: descriptor.expiresAt }
|
|
725
|
+
: {}),
|
|
726
|
+
...(payload !== undefined ? { payload } : {}),
|
|
727
|
+
})
|
|
728
|
+
} catch (error) {
|
|
729
|
+
throw durableGenericFailure(
|
|
730
|
+
ctx,
|
|
731
|
+
persisted,
|
|
732
|
+
`Persisted generic interrupt ${persisted.interruptId} is invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
const emitted = createInterruptBinding(request, {
|
|
736
|
+
batchIndex: binding.batchIndex,
|
|
737
|
+
})
|
|
738
|
+
if (
|
|
739
|
+
emitted.descriptor.responseSchemaHash !== binding.responseSchemaHash ||
|
|
740
|
+
emitted.descriptor.payloadSchemaHash !== binding.payloadSchemaHash ||
|
|
741
|
+
binding.interruptId !== persisted.interruptId
|
|
742
|
+
) {
|
|
743
|
+
throw durableGenericFailure(
|
|
744
|
+
ctx,
|
|
745
|
+
persisted,
|
|
746
|
+
`Persisted generic interrupt ${persisted.interruptId} is stale.`,
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
records.push({
|
|
750
|
+
interruptId: persisted.interruptId,
|
|
751
|
+
payload: descriptor,
|
|
752
|
+
binding,
|
|
753
|
+
genericRequest: request,
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const firstRecord = records[0]
|
|
758
|
+
if (firstRecord === undefined) return undefined
|
|
759
|
+
const interruptedRunId = firstRecord.binding.interruptedRunId
|
|
760
|
+
const generation = firstRecord.binding.generation
|
|
761
|
+
const validated = await validateInterruptResumeBatch({
|
|
762
|
+
threadId: ctx.threadId,
|
|
763
|
+
interruptedRunId,
|
|
764
|
+
generation,
|
|
765
|
+
pending: records,
|
|
766
|
+
resume: resume.filter((entry) =>
|
|
767
|
+
records.some((record) => record.interruptId === entry.interruptId),
|
|
768
|
+
),
|
|
769
|
+
tools,
|
|
770
|
+
})
|
|
771
|
+
if (validated.errors.length > 0 || !validated.resumeToolState) {
|
|
772
|
+
throw new InterruptResumeValidationError(validated.errors)
|
|
773
|
+
}
|
|
774
|
+
type GenericRecord = PendingInterruptResumeRecord & {
|
|
775
|
+
binding: Extract<
|
|
776
|
+
PendingInterruptResumeRecord['binding'],
|
|
777
|
+
{ kind: 'generic' }
|
|
778
|
+
>
|
|
779
|
+
genericRequest: GenericInterruptRequest<
|
|
780
|
+
InterruptDefinition<any, any, any, any>
|
|
781
|
+
>
|
|
782
|
+
}
|
|
783
|
+
const isGenericRecord = (
|
|
784
|
+
record: PendingInterruptResumeRecord,
|
|
785
|
+
): record is GenericRecord =>
|
|
786
|
+
record.binding.kind === 'generic' && record.genericRequest !== undefined
|
|
787
|
+
const genericRecords: Array<{ record: GenericRecord; batchIndex: number }> =
|
|
788
|
+
[]
|
|
789
|
+
const batchIndexes = new Set<number>()
|
|
790
|
+
for (const record of records) {
|
|
791
|
+
if (!isGenericRecord(record)) continue
|
|
792
|
+
const batchIndex = record.binding.batchIndex
|
|
793
|
+
if (batchIndex === undefined || batchIndexes.has(batchIndex)) {
|
|
794
|
+
throw new InterruptResumeValidationError([
|
|
795
|
+
{
|
|
796
|
+
scope: 'batch',
|
|
797
|
+
threadId: ctx.threadId,
|
|
798
|
+
interruptedRunId,
|
|
799
|
+
generation,
|
|
800
|
+
interruptIds: records.map((item) => item.interruptId),
|
|
801
|
+
code: 'stale',
|
|
802
|
+
message:
|
|
803
|
+
'Persisted generic interrupts have duplicate or invalid batch indexes.',
|
|
804
|
+
source: 'server',
|
|
805
|
+
retryable: false,
|
|
806
|
+
},
|
|
807
|
+
])
|
|
808
|
+
}
|
|
809
|
+
batchIndexes.add(batchIndex)
|
|
810
|
+
genericRecords.push({ record, batchIndex })
|
|
811
|
+
}
|
|
812
|
+
genericRecords.sort((left, right) => left.batchIndex - right.batchIndex)
|
|
813
|
+
return {
|
|
814
|
+
...validated.resumeToolState,
|
|
815
|
+
genericInterruptRequests: new Map(
|
|
816
|
+
genericRecords.flatMap(({ record }) =>
|
|
817
|
+
record.genericRequest
|
|
818
|
+
? [[record.interruptId, record.genericRequest] as const]
|
|
819
|
+
: [],
|
|
820
|
+
),
|
|
821
|
+
),
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
381
825
|
function resolvedApprovalDecision(entry: RunAgentResumeItem): boolean {
|
|
382
826
|
if (entry.status === 'cancelled') return false
|
|
383
827
|
const payload = objectValue(entry.payload)
|
|
@@ -438,43 +882,6 @@ function resumeToolStateFromPending(
|
|
|
438
882
|
return { approvals, clientToolResults, cancelledToolCallIds }
|
|
439
883
|
}
|
|
440
884
|
|
|
441
|
-
/**
|
|
442
|
-
* Build the transcript to persist when a run finishes successfully.
|
|
443
|
-
*
|
|
444
|
-
* The chat engine appends an assistant message to the middleware message list
|
|
445
|
-
* only when that turn carries tool calls (to feed the agent loop); a run's
|
|
446
|
-
* terminal *text* reply is never appended. So `ctx.messages` at `onFinish` is
|
|
447
|
-
* missing the assistant's final answer. Reattach it from the finish info —
|
|
448
|
-
* `info.content` is the last turn's accumulated text (reset each cycle) — so
|
|
449
|
-
* the stored thread is the complete conversation a server-authoritative client
|
|
450
|
-
* hydrates on load. A guard avoids duplicating a terminal assistant turn should
|
|
451
|
-
* the engine ever start appending it itself.
|
|
452
|
-
*/
|
|
453
|
-
function finishedTranscript(
|
|
454
|
-
messages: ReadonlyArray<ModelMessage>,
|
|
455
|
-
info: FinishInfo,
|
|
456
|
-
messageId: string | undefined,
|
|
457
|
-
createdAt: Date | undefined,
|
|
458
|
-
): Array<ModelMessage> {
|
|
459
|
-
const transcript = [...messages]
|
|
460
|
-
const last = transcript[transcript.length - 1]
|
|
461
|
-
const alreadyPresent =
|
|
462
|
-
last?.role === 'assistant' &&
|
|
463
|
-
last.toolCalls === undefined &&
|
|
464
|
-
last.content === info.content
|
|
465
|
-
if (info.content && !alreadyPresent) {
|
|
466
|
-
// Stamp the terminal turn with its stream messageId so a hydrated bubble
|
|
467
|
-
// keeps the same identity as the live stream (in-place resume on reload).
|
|
468
|
-
transcript.push({
|
|
469
|
-
role: 'assistant',
|
|
470
|
-
content: info.content,
|
|
471
|
-
...(messageId ? { id: messageId } : {}),
|
|
472
|
-
...(createdAt ? { createdAt } : {}),
|
|
473
|
-
})
|
|
474
|
-
}
|
|
475
|
-
return transcript
|
|
476
|
-
}
|
|
477
|
-
|
|
478
885
|
function interruptPayload(interrupt: unknown): Record<string, unknown> {
|
|
479
886
|
return interrupt && typeof interrupt === 'object'
|
|
480
887
|
? { ...(interrupt as Record<string, unknown>) }
|
|
@@ -1402,14 +1809,14 @@ async function failRun(
|
|
|
1402
1809
|
error: unknown,
|
|
1403
1810
|
usage?: TokenUsage,
|
|
1404
1811
|
): Promise<void> {
|
|
1405
|
-
|
|
1406
|
-
// here: the middleware sees an opaque thrown value, and inventing a `code`
|
|
1407
|
-
// from it would fabricate the stable classification consumers branch on. A
|
|
1408
|
-
// provider-supplied code reaches the record through the adapter layer.
|
|
1812
|
+
const runError = toRunErrorPayload(error)
|
|
1409
1813
|
await runs?.update(runId, {
|
|
1410
1814
|
status: 'failed',
|
|
1411
1815
|
finishedAt: Date.now(),
|
|
1412
|
-
error: {
|
|
1816
|
+
error: {
|
|
1817
|
+
message: runError.message,
|
|
1818
|
+
...(runError.code !== undefined ? { code: runError.code } : {}),
|
|
1819
|
+
},
|
|
1413
1820
|
...(usage ? { usage } : {}),
|
|
1414
1821
|
})
|
|
1415
1822
|
}
|
|
@@ -1527,6 +1934,7 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1527
1934
|
|
|
1528
1935
|
const provides = [
|
|
1529
1936
|
PersistenceCapability,
|
|
1937
|
+
PersistenceCompletionCapability,
|
|
1530
1938
|
...(wantsInterrupts ? [InterruptsCapability] : []),
|
|
1531
1939
|
]
|
|
1532
1940
|
|
|
@@ -1536,9 +1944,27 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1536
1944
|
setup(ctx: ChatMiddlewareContext) {
|
|
1537
1945
|
providePersistence(ctx, persistence)
|
|
1538
1946
|
|
|
1947
|
+
let resolveCompletion: () => void = () => undefined
|
|
1948
|
+
let rejectCompletion: (error: unknown) => void = () => undefined
|
|
1949
|
+
const completion = new Promise<void>((resolve, reject) => {
|
|
1950
|
+
resolveCompletion = resolve
|
|
1951
|
+
rejectCompletion = reject
|
|
1952
|
+
})
|
|
1953
|
+
// Consumers may not need this capability. Mark the rejection handled while
|
|
1954
|
+
// preserving the original promise for callers that do await it.
|
|
1955
|
+
void completion.catch(() => undefined)
|
|
1956
|
+
|
|
1539
1957
|
runState.set(ctx, {
|
|
1540
1958
|
merged: false,
|
|
1541
1959
|
interrupted: false,
|
|
1960
|
+
completion: {
|
|
1961
|
+
promise: completion,
|
|
1962
|
+
resolve: resolveCompletion,
|
|
1963
|
+
reject: rejectCompletion,
|
|
1964
|
+
},
|
|
1965
|
+
})
|
|
1966
|
+
providePersistenceCompletion(ctx, {
|
|
1967
|
+
waitForRunCompletion: () => completion,
|
|
1542
1968
|
})
|
|
1543
1969
|
|
|
1544
1970
|
if (wantsInterrupts && persistence.stores.interrupts) {
|
|
@@ -1575,11 +2001,15 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1575
2001
|
const pending = await persistence.stores.interrupts.listPending(
|
|
1576
2002
|
ctx.threadId,
|
|
1577
2003
|
)
|
|
1578
|
-
// Gate
|
|
1579
|
-
//
|
|
2004
|
+
// Gate only records that this chat owns. A foreign AG-UI interrupt can
|
|
2005
|
+
// share the durable thread, but its owner resolves it outside this
|
|
2006
|
+
// resume protocol. Including it would deadlock this chat resume.
|
|
2007
|
+
const ownedPending = pending.filter(isChatOwnedPendingInterrupt)
|
|
2008
|
+
rejectMixedRunPending(ownedPending, ctx)
|
|
1580
2009
|
const resumeByInterruptId = validatePendingResumes(
|
|
1581
|
-
|
|
2010
|
+
ownedPending,
|
|
1582
2011
|
config.resume,
|
|
2012
|
+
ctx,
|
|
1583
2013
|
)
|
|
1584
2014
|
// Persistence is the server-authoritative resume path: translate the
|
|
1585
2015
|
// persisted interrupts into the engine's resume tool state and CLEAR
|
|
@@ -1588,18 +2018,29 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1588
2018
|
// persistence flow deliberately omits).
|
|
1589
2019
|
if ((config.resume?.length ?? 0) > 0) {
|
|
1590
2020
|
const resumeToolState = resumeToolStateFromPending(
|
|
1591
|
-
|
|
2021
|
+
ownedPending,
|
|
1592
2022
|
resumeByInterruptId,
|
|
1593
2023
|
)
|
|
2024
|
+
const genericResumeState = await durableGenericResumeState(
|
|
2025
|
+
ctx,
|
|
2026
|
+
ownedPending,
|
|
2027
|
+
config.resume ?? [],
|
|
2028
|
+
config.tools,
|
|
2029
|
+
)
|
|
1594
2030
|
patch.resume = []
|
|
1595
|
-
if (resumeToolState
|
|
2031
|
+
if (resumeToolState || genericResumeState) {
|
|
2032
|
+
patch.resumeToolState = mergeResumeToolState(
|
|
2033
|
+
resumeToolState,
|
|
2034
|
+
genericResumeState,
|
|
2035
|
+
)
|
|
2036
|
+
}
|
|
1596
2037
|
}
|
|
1597
2038
|
// Defer marking these interrupts resolved/cancelled until the run
|
|
1598
2039
|
// succeeds (see commitPendingResumes). Committing here would consume the
|
|
1599
2040
|
// approval even if the run then failed, breaking a retry.
|
|
1600
2041
|
const state = runState.get(ctx)
|
|
1601
|
-
if (state &&
|
|
1602
|
-
state.pendingResumes = { pending, resumeByInterruptId }
|
|
2042
|
+
if (state && ownedPending.length > 0) {
|
|
2043
|
+
state.pendingResumes = { pending: ownedPending, resumeByInterruptId }
|
|
1603
2044
|
}
|
|
1604
2045
|
}
|
|
1605
2046
|
|
|
@@ -1630,11 +2071,9 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1630
2071
|
},
|
|
1631
2072
|
|
|
1632
2073
|
async onChunk(ctx: ChatMiddlewareContext, chunk: StreamChunk) {
|
|
1633
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
|
|
1636
|
-
// bubble in place.
|
|
1637
|
-
if (ctx.phase === 'modelStream') {
|
|
2074
|
+
// Capture the current assistant turn's identity for optional in-progress
|
|
2075
|
+
// snapshots. Completed messages already live in `ctx.messages`.
|
|
2076
|
+
if (snapshotStreaming && ctx.phase === 'modelStream') {
|
|
1638
2077
|
const s = runState.get(ctx)
|
|
1639
2078
|
if (s && chunk.type === 'TEXT_MESSAGE_START') {
|
|
1640
2079
|
// An empty/malformed messageId means "no identity" (matching the
|
|
@@ -1661,9 +2100,8 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1661
2100
|
|
|
1662
2101
|
// (B) Optional throttled snapshot of the in-progress assistant reply, so
|
|
1663
2102
|
// partial output survives a crash/reload before onFinish. Off unless
|
|
1664
|
-
// `snapshotStreaming` is set.
|
|
1665
|
-
//
|
|
1666
|
-
// `ctx.messages`, never a streaming text reply), then persist
|
|
2103
|
+
// `snapshotStreaming` is set. The completed turn enters `ctx.messages`
|
|
2104
|
+
// only after streaming ends, so accumulate its text here and persist
|
|
1667
2105
|
// `ctx.messages` + that partial assistant message (tagged with its id).
|
|
1668
2106
|
if (
|
|
1669
2107
|
snapshotStreaming &&
|
|
@@ -1749,21 +2187,30 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1749
2187
|
// resumes stay pending so a retry can re-apply them. Completing the run
|
|
1750
2188
|
// or consuming approvals before the durable history lands leaves a
|
|
1751
2189
|
// "finished" run whose transcript is missing the terminal turn.
|
|
1752
|
-
|
|
1753
|
-
ctx.threadId,
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
2190
|
+
try {
|
|
2191
|
+
await messageStore.saveThread(ctx.threadId, [...ctx.messages])
|
|
2192
|
+
await commitPendingResumes(state, persistence.stores.interrupts)
|
|
2193
|
+
await completeRun(runs, ctx.runId, state?.usage ?? info.usage)
|
|
2194
|
+
state?.completion?.resolve()
|
|
2195
|
+
} catch (error) {
|
|
2196
|
+
// Core has already selected its terminal hook. Persist the failed run
|
|
2197
|
+
// here, so a failed transcript save or batch write does not leave an
|
|
2198
|
+
// interrupted or completed run whose pending records need retrying.
|
|
2199
|
+
try {
|
|
2200
|
+
await failRun(runs, ctx.runId, error, state?.usage)
|
|
2201
|
+
} finally {
|
|
2202
|
+
state?.completion?.reject(error)
|
|
2203
|
+
}
|
|
2204
|
+
throw error
|
|
2205
|
+
}
|
|
1763
2206
|
},
|
|
1764
2207
|
|
|
1765
2208
|
async onError(ctx: ChatMiddlewareContext, info: ErrorInfo) {
|
|
1766
|
-
|
|
2209
|
+
try {
|
|
2210
|
+
await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage)
|
|
2211
|
+
} finally {
|
|
2212
|
+
runState.get(ctx)?.completion?.reject(info.error)
|
|
2213
|
+
}
|
|
1767
2214
|
},
|
|
1768
2215
|
|
|
1769
2216
|
async onAbort(ctx: ChatMiddlewareContext, info: AbortInfo) {
|
|
@@ -1773,10 +2220,6 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1773
2220
|
// (`info.cancelRequested`, set when the cancel aborted this host's signal)
|
|
1774
2221
|
// and durable (`RunRecord.cancelRequested`, the only channel that reaches
|
|
1775
2222
|
// a run being driven elsewhere).
|
|
1776
|
-
const cancelled =
|
|
1777
|
-
info.cancelRequested === true ||
|
|
1778
|
-
(runs !== undefined && (await wasCancelRequested(runs, ctx.runId)))
|
|
1779
|
-
|
|
1780
2223
|
// A run paused at an interrupt boundary is waiting for a HUMAN, not for
|
|
1781
2224
|
// this socket. `chat()` skips its terminal hook at an actionable-wait
|
|
1782
2225
|
// boundary, so its `finally` routes the disconnect here — and
|
|
@@ -1785,9 +2228,21 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
|
|
|
1785
2228
|
// still threw on the next request. An explicit cancel is different: the
|
|
1786
2229
|
// user gave up on the approval, so the cancel band stays authoritative.
|
|
1787
2230
|
const state = runState.get(ctx)
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
2231
|
+
let terminal = false
|
|
2232
|
+
try {
|
|
2233
|
+
// The durable cancel read is best-effort. It must not bypass the
|
|
2234
|
+
// terminal persistence path or prevent the completion promise from
|
|
2235
|
+
// settling when the run store is unavailable.
|
|
2236
|
+
const cancelled =
|
|
2237
|
+
info.cancelRequested === true ||
|
|
2238
|
+
(runs !== undefined && (await wasCancelRequested(runs, ctx.runId)))
|
|
2239
|
+
terminal =
|
|
2240
|
+
cancelled || (!detachableRun(ctx) && state?.interrupted !== true)
|
|
2241
|
+
if (terminal) {
|
|
2242
|
+
await abortRun(runs, ctx.runId, state?.usage)
|
|
2243
|
+
}
|
|
2244
|
+
} finally {
|
|
2245
|
+
if (terminal) state?.completion?.reject(info.reason)
|
|
1791
2246
|
}
|
|
1792
2247
|
// A plain disconnect on a detachable or interrupted run: write NOTHING.
|
|
1793
2248
|
// Either the agent is still running and a later attach can take it over
|