@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/dist/esm/middleware.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { validateChatPersistenceStores, validateGenerationPersistenceStores } from "./types.js";
|
|
2
|
-
import { InterruptsCapability, PersistenceCapability, provideInterrupts, providePersistence } from "./capabilities.js";
|
|
2
|
+
import { InterruptsCapability, PersistenceCapability, PersistenceCompletionCapability, provideInterrupts, providePersistence, providePersistenceCompletion } from "./capabilities.js";
|
|
3
3
|
import { artifactBlobKey } from "./retrieve.js";
|
|
4
|
-
import { defineChatMiddleware, getDetachableRun, wasCancelRequested } from "@tanstack/ai";
|
|
5
|
-
import { providePendingTurn } from "@tanstack/ai/adapter-internals";
|
|
4
|
+
import { InterruptResumeValidationError, defineChatMiddleware, getDetachableRun, readInterruptBinding, validateInterruptResumeBatch, wasCancelRequested } from "@tanstack/ai";
|
|
5
|
+
import { createInterruptBinding, getGenericInterruptDefinitionRegistry, providePendingTurn, rehydrateInterruptRequest, toRunErrorPayload } from "@tanstack/ai/adapter-internals";
|
|
6
6
|
import { base64ToUint8Array } from "@tanstack/ai-utils";
|
|
7
7
|
//#region src/middleware.ts
|
|
8
8
|
/**
|
|
@@ -23,30 +23,116 @@ var DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS = 3e4;
|
|
|
23
23
|
var DEFAULT_MAX_ARTIFACT_BYTES = 1073741824;
|
|
24
24
|
var runState = /* @__PURE__ */ new WeakMap();
|
|
25
25
|
var validResumeStatuses = /* @__PURE__ */ new Set(["resolved", "cancelled"]);
|
|
26
|
-
function
|
|
26
|
+
function mergeMaps(left, right) {
|
|
27
|
+
if (!left && !right) return void 0;
|
|
28
|
+
return new Map([...left ?? [], ...right ?? []]);
|
|
29
|
+
}
|
|
30
|
+
function mergeSets(left, right) {
|
|
31
|
+
if (!left && !right) return void 0;
|
|
32
|
+
return /* @__PURE__ */ new Set([...left ?? [], ...right ?? []]);
|
|
33
|
+
}
|
|
34
|
+
function mergeResumeToolState(left, right) {
|
|
35
|
+
if (!left) return right;
|
|
36
|
+
if (!right) return left;
|
|
37
|
+
return {
|
|
38
|
+
approvals: mergeMaps(left.approvals, right.approvals),
|
|
39
|
+
clientToolResults: mergeMaps(left.clientToolResults, right.clientToolResults),
|
|
40
|
+
genericInterrupts: mergeMaps(left.genericInterrupts, right.genericInterrupts),
|
|
41
|
+
genericInterruptRequests: mergeMaps(left.genericInterruptRequests, right.genericInterruptRequests),
|
|
42
|
+
deniedToolResults: mergeMaps(left.deniedToolResults, right.deniedToolResults),
|
|
43
|
+
cancelledToolCallIds: mergeSets(left.cancelledToolCallIds, right.cancelledToolCallIds)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function rejectMixedRunPending(pending, ctx) {
|
|
47
|
+
if (new Set(pending.map((interrupt) => interrupt.runId)).size <= 1) return;
|
|
48
|
+
throw new InterruptResumeValidationError([{
|
|
49
|
+
scope: "batch",
|
|
50
|
+
threadId: ctx.threadId,
|
|
51
|
+
interruptedRunId: ctx.runId,
|
|
52
|
+
generation: 0,
|
|
53
|
+
interruptIds: pending.map((interrupt) => interrupt.interruptId),
|
|
54
|
+
code: "stale",
|
|
55
|
+
message: "Thread has pending interrupts from more than one run.",
|
|
56
|
+
source: "server",
|
|
57
|
+
retryable: false
|
|
58
|
+
}]);
|
|
59
|
+
}
|
|
60
|
+
function validatePendingResumes(pending, resume, ctx) {
|
|
61
|
+
const interruptedRunId = pending[0]?.runId ?? ctx.runId;
|
|
62
|
+
const failure = (interruptId, code, message) => {
|
|
63
|
+
throw new InterruptResumeValidationError([{
|
|
64
|
+
scope: "item",
|
|
65
|
+
threadId: ctx.threadId,
|
|
66
|
+
interruptedRunId,
|
|
67
|
+
generation: 0,
|
|
68
|
+
interruptId,
|
|
69
|
+
code,
|
|
70
|
+
message,
|
|
71
|
+
source: "client",
|
|
72
|
+
retryable: false
|
|
73
|
+
}, {
|
|
74
|
+
scope: "batch",
|
|
75
|
+
threadId: ctx.threadId,
|
|
76
|
+
interruptedRunId,
|
|
77
|
+
generation: 0,
|
|
78
|
+
interruptIds: pending.map((interrupt) => interrupt.interruptId),
|
|
79
|
+
code: code === "conflict" ? "conflict" : "incomplete-batch",
|
|
80
|
+
message: "Resume entries must resolve or cancel the complete interrupt batch.",
|
|
81
|
+
source: "client",
|
|
82
|
+
retryable: false
|
|
83
|
+
}]);
|
|
84
|
+
};
|
|
27
85
|
const pendingInterruptIds = new Set(pending.map((interrupt) => interrupt.interruptId));
|
|
28
|
-
const resumeByInterruptId = new Map(
|
|
86
|
+
const resumeByInterruptId = /* @__PURE__ */ new Map();
|
|
87
|
+
for (const entry of resume ?? []) {
|
|
88
|
+
if (resumeByInterruptId.has(entry.interruptId)) return failure(entry.interruptId, "conflict", `Interrupt ${entry.interruptId} has duplicate resume entries.`);
|
|
89
|
+
resumeByInterruptId.set(entry.interruptId, entry);
|
|
90
|
+
}
|
|
29
91
|
if (pending.length === 0) {
|
|
30
92
|
const staleEntry = resume?.[0];
|
|
31
|
-
if (staleEntry)
|
|
93
|
+
if (staleEntry) return failure(staleEntry.interruptId, "unknown-interrupt", `Resume entry references non-pending interrupt ${staleEntry.interruptId}.`);
|
|
32
94
|
return resumeByInterruptId;
|
|
33
95
|
}
|
|
34
|
-
|
|
96
|
+
const firstPending = pending[0];
|
|
97
|
+
if (firstPending === void 0) return resumeByInterruptId;
|
|
98
|
+
if (!resume || resume.length === 0) return failure(firstPending.interruptId, "unknown-interrupt", `Thread has pending interrupts; resume is required before accepting new input.`);
|
|
35
99
|
for (const interrupt of pending) {
|
|
36
100
|
const entry = resumeByInterruptId.get(interrupt.interruptId);
|
|
37
|
-
if (!entry)
|
|
38
|
-
if (!validResumeStatuses.has(entry.status))
|
|
101
|
+
if (!entry) return failure(interrupt.interruptId, "unknown-interrupt", `Missing resume entry for pending interrupt ${interrupt.interruptId}.`);
|
|
102
|
+
if (!validResumeStatuses.has(entry.status)) return failure(interrupt.interruptId, "unknown-interrupt", `Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`);
|
|
39
103
|
}
|
|
40
|
-
for (const entry of resume) if (!pendingInterruptIds.has(entry.interruptId))
|
|
104
|
+
for (const entry of resume) if (!pendingInterruptIds.has(entry.interruptId)) return failure(entry.interruptId, "unknown-interrupt", `Resume entry references non-pending interrupt ${entry.interruptId}.`);
|
|
41
105
|
return resumeByInterruptId;
|
|
42
106
|
}
|
|
43
107
|
async function applyPendingResumes(pending, resumeByInterruptId, interrupts) {
|
|
108
|
+
const entries = [];
|
|
44
109
|
for (const interrupt of pending) {
|
|
45
110
|
const entry = resumeByInterruptId.get(interrupt.interruptId);
|
|
46
111
|
if (!entry) continue;
|
|
47
|
-
if (entry.status === "resolved")
|
|
48
|
-
|
|
112
|
+
if (entry.status === "resolved") entries.push({
|
|
113
|
+
interruptId: interrupt.interruptId,
|
|
114
|
+
status: "resolved",
|
|
115
|
+
response: entry.payload
|
|
116
|
+
});
|
|
117
|
+
else entries.push({
|
|
118
|
+
interruptId: interrupt.interruptId,
|
|
119
|
+
status: "cancelled"
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (interrupts.commitBatch) {
|
|
123
|
+
await interrupts.commitBatch(entries);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const ids = /* @__PURE__ */ new Set();
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (ids.has(entry.interruptId)) throw new Error(`Interrupt batch contains duplicate id: ${entry.interruptId}.`);
|
|
129
|
+
ids.add(entry.interruptId);
|
|
130
|
+
const existing = await interrupts.get(entry.interruptId);
|
|
131
|
+
if (!existing) throw new Error(`Interrupt batch references missing id: ${entry.interruptId}.`);
|
|
132
|
+
if (existing.status !== "pending") throw new Error(`Interrupt batch references non-pending id: ${entry.interruptId}.`);
|
|
49
133
|
}
|
|
134
|
+
for (const entry of entries) if (entry.status === "resolved") await interrupts.resolve(entry.interruptId, entry.response);
|
|
135
|
+
else await interrupts.cancel(entry.interruptId);
|
|
50
136
|
}
|
|
51
137
|
/**
|
|
52
138
|
* Commit the resumes stashed in `onConfig`, marking each resumed interrupt
|
|
@@ -72,6 +158,147 @@ function interruptKind(interrupt) {
|
|
|
72
158
|
const metadata = objectValue(interrupt.payload.metadata);
|
|
73
159
|
return metadata ? stringField(metadata, "kind") : void 0;
|
|
74
160
|
}
|
|
161
|
+
function hasReservedInterruptBinding(payload) {
|
|
162
|
+
const metadata = objectValue(objectValue(payload)?.metadata);
|
|
163
|
+
return !!metadata && "tanstack:interruptBinding" in metadata;
|
|
164
|
+
}
|
|
165
|
+
function isPersistedInterruptDescriptor(value) {
|
|
166
|
+
const record = objectValue(value);
|
|
167
|
+
return !!record && typeof record.id === "string" && typeof record.reason === "string" && typeof record.message === "string";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Does this pending record belong to the TanStack chat resume protocol?
|
|
171
|
+
*
|
|
172
|
+
* An external system can persist an AG-UI descriptor in the same durable
|
|
173
|
+
* thread. A descriptor without a TanStack binding or legacy tool marker stays
|
|
174
|
+
* pending for its owner, but it does not make this resume incomplete. Older
|
|
175
|
+
* opaque records remain owned because their provenance cannot be known.
|
|
176
|
+
*/
|
|
177
|
+
function isChatOwnedPendingInterrupt(interrupt) {
|
|
178
|
+
const kind = interruptKind(interrupt);
|
|
179
|
+
return !isPersistedInterruptDescriptor(interrupt.payload) || stringField(interrupt.payload, "toolCallId") !== void 0 || kind === "approval" || kind === "client_tool" || hasReservedInterruptBinding(interrupt.payload);
|
|
180
|
+
}
|
|
181
|
+
function durableGenericFailure(ctx, persisted, message) {
|
|
182
|
+
return new InterruptResumeValidationError([{
|
|
183
|
+
scope: "item",
|
|
184
|
+
threadId: ctx.threadId,
|
|
185
|
+
interruptedRunId: persisted.runId || ctx.runId,
|
|
186
|
+
generation: 0,
|
|
187
|
+
interruptId: persisted.interruptId,
|
|
188
|
+
code: "stale",
|
|
189
|
+
message,
|
|
190
|
+
source: "server",
|
|
191
|
+
retryable: false
|
|
192
|
+
}, {
|
|
193
|
+
scope: "batch",
|
|
194
|
+
threadId: ctx.threadId,
|
|
195
|
+
interruptedRunId: persisted.runId || ctx.runId,
|
|
196
|
+
generation: 0,
|
|
197
|
+
interruptIds: [persisted.interruptId],
|
|
198
|
+
code: "item-validation-failed",
|
|
199
|
+
message: "One or more persisted interrupt records are invalid.",
|
|
200
|
+
source: "server",
|
|
201
|
+
retryable: false
|
|
202
|
+
}]);
|
|
203
|
+
}
|
|
204
|
+
async function durableGenericResumeState(ctx, pending, resume, tools) {
|
|
205
|
+
const registry = getGenericInterruptDefinitionRegistry(ctx, { optional: true });
|
|
206
|
+
const records = [];
|
|
207
|
+
for (const persisted of pending) {
|
|
208
|
+
if (!isPersistedInterruptDescriptor(persisted.payload)) {
|
|
209
|
+
if (hasReservedInterruptBinding(persisted.payload)) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has an invalid binding descriptor.`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const descriptor = persisted.payload;
|
|
213
|
+
const binding = readInterruptBinding(descriptor);
|
|
214
|
+
if (!binding) {
|
|
215
|
+
if (hasReservedInterruptBinding(descriptor)) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has an invalid or incomplete binding.`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (descriptor.id !== persisted.interruptId || binding.interruptId !== persisted.interruptId || binding.interruptedRunId !== persisted.runId || binding.generation !== 0) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has stale correlation metadata.`);
|
|
219
|
+
if (binding.kind !== "generic") {
|
|
220
|
+
records.push({
|
|
221
|
+
interruptId: persisted.interruptId,
|
|
222
|
+
payload: descriptor,
|
|
223
|
+
binding
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!binding.definitionId || !binding.key || binding.batchIndex === void 0) {
|
|
228
|
+
records.push({
|
|
229
|
+
interruptId: persisted.interruptId,
|
|
230
|
+
payload: descriptor,
|
|
231
|
+
binding
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!registry) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} cannot be restored because no interrupt registry is available.`);
|
|
236
|
+
const definition = registry.definitions.get(binding.definitionId);
|
|
237
|
+
if (!definition) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt definition ${binding.definitionId} is unavailable.`);
|
|
238
|
+
const payload = objectValue(descriptor.metadata)?.["tanstack:interruptPayload"];
|
|
239
|
+
let request;
|
|
240
|
+
try {
|
|
241
|
+
request = rehydrateInterruptRequest(definition, {
|
|
242
|
+
key: binding.key,
|
|
243
|
+
reason: descriptor.reason,
|
|
244
|
+
message: descriptor.message,
|
|
245
|
+
...descriptor.expiresAt !== void 0 ? { expiresAt: descriptor.expiresAt } : {},
|
|
246
|
+
...payload !== void 0 ? { payload } : {}
|
|
247
|
+
});
|
|
248
|
+
} catch (error) {
|
|
249
|
+
throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
250
|
+
}
|
|
251
|
+
const emitted = createInterruptBinding(request, { batchIndex: binding.batchIndex });
|
|
252
|
+
if (emitted.descriptor.responseSchemaHash !== binding.responseSchemaHash || emitted.descriptor.payloadSchemaHash !== binding.payloadSchemaHash || binding.interruptId !== persisted.interruptId) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} is stale.`);
|
|
253
|
+
records.push({
|
|
254
|
+
interruptId: persisted.interruptId,
|
|
255
|
+
payload: descriptor,
|
|
256
|
+
binding,
|
|
257
|
+
genericRequest: request
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const firstRecord = records[0];
|
|
261
|
+
if (firstRecord === void 0) return void 0;
|
|
262
|
+
const interruptedRunId = firstRecord.binding.interruptedRunId;
|
|
263
|
+
const generation = firstRecord.binding.generation;
|
|
264
|
+
const validated = await validateInterruptResumeBatch({
|
|
265
|
+
threadId: ctx.threadId,
|
|
266
|
+
interruptedRunId,
|
|
267
|
+
generation,
|
|
268
|
+
pending: records,
|
|
269
|
+
resume: resume.filter((entry) => records.some((record) => record.interruptId === entry.interruptId)),
|
|
270
|
+
tools
|
|
271
|
+
});
|
|
272
|
+
if (validated.errors.length > 0 || !validated.resumeToolState) throw new InterruptResumeValidationError(validated.errors);
|
|
273
|
+
const isGenericRecord = (record) => record.binding.kind === "generic" && record.genericRequest !== void 0;
|
|
274
|
+
const genericRecords = [];
|
|
275
|
+
const batchIndexes = /* @__PURE__ */ new Set();
|
|
276
|
+
for (const record of records) {
|
|
277
|
+
if (!isGenericRecord(record)) continue;
|
|
278
|
+
const batchIndex = record.binding.batchIndex;
|
|
279
|
+
if (batchIndex === void 0 || batchIndexes.has(batchIndex)) throw new InterruptResumeValidationError([{
|
|
280
|
+
scope: "batch",
|
|
281
|
+
threadId: ctx.threadId,
|
|
282
|
+
interruptedRunId,
|
|
283
|
+
generation,
|
|
284
|
+
interruptIds: records.map((item) => item.interruptId),
|
|
285
|
+
code: "stale",
|
|
286
|
+
message: "Persisted generic interrupts have duplicate or invalid batch indexes.",
|
|
287
|
+
source: "server",
|
|
288
|
+
retryable: false
|
|
289
|
+
}]);
|
|
290
|
+
batchIndexes.add(batchIndex);
|
|
291
|
+
genericRecords.push({
|
|
292
|
+
record,
|
|
293
|
+
batchIndex
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
genericRecords.sort((left, right) => left.batchIndex - right.batchIndex);
|
|
297
|
+
return {
|
|
298
|
+
...validated.resumeToolState,
|
|
299
|
+
genericInterruptRequests: new Map(genericRecords.flatMap(({ record }) => record.genericRequest ? [[record.interruptId, record.genericRequest]] : []))
|
|
300
|
+
};
|
|
301
|
+
}
|
|
75
302
|
function resolvedApprovalDecision(entry) {
|
|
76
303
|
if (entry.status === "cancelled") return false;
|
|
77
304
|
const payload = objectValue(entry.payload);
|
|
@@ -109,30 +336,6 @@ function resumeToolStateFromPending(pending, resumeByInterruptId) {
|
|
|
109
336
|
cancelledToolCallIds
|
|
110
337
|
};
|
|
111
338
|
}
|
|
112
|
-
/**
|
|
113
|
-
* Build the transcript to persist when a run finishes successfully.
|
|
114
|
-
*
|
|
115
|
-
* The chat engine appends an assistant message to the middleware message list
|
|
116
|
-
* only when that turn carries tool calls (to feed the agent loop); a run's
|
|
117
|
-
* terminal *text* reply is never appended. So `ctx.messages` at `onFinish` is
|
|
118
|
-
* missing the assistant's final answer. Reattach it from the finish info —
|
|
119
|
-
* `info.content` is the last turn's accumulated text (reset each cycle) — so
|
|
120
|
-
* the stored thread is the complete conversation a server-authoritative client
|
|
121
|
-
* hydrates on load. A guard avoids duplicating a terminal assistant turn should
|
|
122
|
-
* the engine ever start appending it itself.
|
|
123
|
-
*/
|
|
124
|
-
function finishedTranscript(messages, info, messageId, createdAt) {
|
|
125
|
-
const transcript = [...messages];
|
|
126
|
-
const last = transcript[transcript.length - 1];
|
|
127
|
-
const alreadyPresent = last?.role === "assistant" && last.toolCalls === void 0 && last.content === info.content;
|
|
128
|
-
if (info.content && !alreadyPresent) transcript.push({
|
|
129
|
-
role: "assistant",
|
|
130
|
-
content: info.content,
|
|
131
|
-
...messageId ? { id: messageId } : {},
|
|
132
|
-
...createdAt ? { createdAt } : {}
|
|
133
|
-
});
|
|
134
|
-
return transcript;
|
|
135
|
-
}
|
|
136
339
|
function interruptPayload(interrupt) {
|
|
137
340
|
return interrupt && typeof interrupt === "object" ? { ...interrupt } : { value: interrupt };
|
|
138
341
|
}
|
|
@@ -703,10 +906,14 @@ async function completeRun(runs, runId, usage) {
|
|
|
703
906
|
});
|
|
704
907
|
}
|
|
705
908
|
async function failRun(runs, runId, error, usage) {
|
|
909
|
+
const runError = toRunErrorPayload(error);
|
|
706
910
|
await runs?.update(runId, {
|
|
707
911
|
status: "failed",
|
|
708
912
|
finishedAt: Date.now(),
|
|
709
|
-
error: {
|
|
913
|
+
error: {
|
|
914
|
+
message: runError.message,
|
|
915
|
+
...runError.code !== void 0 ? { code: runError.code } : {}
|
|
916
|
+
},
|
|
710
917
|
...usage ? { usage } : {}
|
|
711
918
|
});
|
|
712
919
|
}
|
|
@@ -760,16 +967,33 @@ function withPersistence(persistence, options = {}) {
|
|
|
760
967
|
const { wantsInterrupts, runs } = resolvePersistencePlan(persistence);
|
|
761
968
|
const messageStore = persistence.stores.messages;
|
|
762
969
|
if (!messageStore) throw new Error("Chat persistence requires stores.messages.");
|
|
763
|
-
const provides = [
|
|
970
|
+
const provides = [
|
|
971
|
+
PersistenceCapability,
|
|
972
|
+
PersistenceCompletionCapability,
|
|
973
|
+
...wantsInterrupts ? [InterruptsCapability] : []
|
|
974
|
+
];
|
|
764
975
|
return defineChatMiddleware({
|
|
765
976
|
name: "chat-persistence",
|
|
766
977
|
provides,
|
|
767
978
|
setup(ctx) {
|
|
768
979
|
providePersistence(ctx, persistence);
|
|
980
|
+
let resolveCompletion = () => void 0;
|
|
981
|
+
let rejectCompletion = () => void 0;
|
|
982
|
+
const completion = new Promise((resolve, reject) => {
|
|
983
|
+
resolveCompletion = resolve;
|
|
984
|
+
rejectCompletion = reject;
|
|
985
|
+
});
|
|
986
|
+
completion.catch(() => void 0);
|
|
769
987
|
runState.set(ctx, {
|
|
770
988
|
merged: false,
|
|
771
|
-
interrupted: false
|
|
989
|
+
interrupted: false,
|
|
990
|
+
completion: {
|
|
991
|
+
promise: completion,
|
|
992
|
+
resolve: resolveCompletion,
|
|
993
|
+
reject: rejectCompletion
|
|
994
|
+
}
|
|
772
995
|
});
|
|
996
|
+
providePersistenceCompletion(ctx, { waitForRunCompletion: () => completion });
|
|
773
997
|
if (wantsInterrupts && persistence.stores.interrupts) provideInterrupts(ctx, persistence.stores.interrupts);
|
|
774
998
|
providePendingTurn(ctx, { snapshot: async () => {
|
|
775
999
|
const stored = await messageStore.loadThread(ctx.threadId);
|
|
@@ -781,16 +1005,18 @@ function withPersistence(persistence, options = {}) {
|
|
|
781
1005
|
if (ctx.phase !== "init") return;
|
|
782
1006
|
const patch = {};
|
|
783
1007
|
if (wantsInterrupts && persistence.stores.interrupts) {
|
|
784
|
-
const
|
|
785
|
-
|
|
1008
|
+
const ownedPending = (await persistence.stores.interrupts.listPending(ctx.threadId)).filter(isChatOwnedPendingInterrupt);
|
|
1009
|
+
rejectMixedRunPending(ownedPending, ctx);
|
|
1010
|
+
const resumeByInterruptId = validatePendingResumes(ownedPending, config.resume, ctx);
|
|
786
1011
|
if ((config.resume?.length ?? 0) > 0) {
|
|
787
|
-
const resumeToolState = resumeToolStateFromPending(
|
|
1012
|
+
const resumeToolState = resumeToolStateFromPending(ownedPending, resumeByInterruptId);
|
|
1013
|
+
const genericResumeState = await durableGenericResumeState(ctx, ownedPending, config.resume ?? [], config.tools);
|
|
788
1014
|
patch.resume = [];
|
|
789
|
-
if (resumeToolState) patch.resumeToolState = resumeToolState;
|
|
1015
|
+
if (resumeToolState || genericResumeState) patch.resumeToolState = mergeResumeToolState(resumeToolState, genericResumeState);
|
|
790
1016
|
}
|
|
791
1017
|
const state = runState.get(ctx);
|
|
792
|
-
if (state &&
|
|
793
|
-
pending,
|
|
1018
|
+
if (state && ownedPending.length > 0) state.pendingResumes = {
|
|
1019
|
+
pending: ownedPending,
|
|
794
1020
|
resumeByInterruptId
|
|
795
1021
|
};
|
|
796
1022
|
}
|
|
@@ -810,7 +1036,7 @@ function withPersistence(persistence, options = {}) {
|
|
|
810
1036
|
} catch {}
|
|
811
1037
|
},
|
|
812
1038
|
async onChunk(ctx, chunk) {
|
|
813
|
-
if (ctx.phase === "modelStream") {
|
|
1039
|
+
if (snapshotStreaming && ctx.phase === "modelStream") {
|
|
814
1040
|
const s = runState.get(ctx);
|
|
815
1041
|
if (s && chunk.type === "TEXT_MESSAGE_START") {
|
|
816
1042
|
s.streamingMessageId = typeof chunk.messageId === "string" && chunk.messageId !== "" ? chunk.messageId : void 0;
|
|
@@ -866,19 +1092,35 @@ function withPersistence(persistence, options = {}) {
|
|
|
866
1092
|
async onFinish(ctx, info) {
|
|
867
1093
|
const state = runState.get(ctx);
|
|
868
1094
|
if (state?.interrupted) return;
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1095
|
+
try {
|
|
1096
|
+
await messageStore.saveThread(ctx.threadId, [...ctx.messages]);
|
|
1097
|
+
await commitPendingResumes(state, persistence.stores.interrupts);
|
|
1098
|
+
await completeRun(runs, ctx.runId, state?.usage ?? info.usage);
|
|
1099
|
+
state?.completion?.resolve();
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
try {
|
|
1102
|
+
await failRun(runs, ctx.runId, error, state?.usage);
|
|
1103
|
+
} finally {
|
|
1104
|
+
state?.completion?.reject(error);
|
|
1105
|
+
}
|
|
1106
|
+
throw error;
|
|
1107
|
+
}
|
|
872
1108
|
},
|
|
873
1109
|
async onError(ctx, info) {
|
|
874
|
-
|
|
1110
|
+
try {
|
|
1111
|
+
await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage);
|
|
1112
|
+
} finally {
|
|
1113
|
+
runState.get(ctx)?.completion?.reject(info.error);
|
|
1114
|
+
}
|
|
875
1115
|
},
|
|
876
1116
|
async onAbort(ctx, info) {
|
|
877
|
-
const cancelled = info.cancelRequested === true || runs !== void 0 && await wasCancelRequested(runs, ctx.runId);
|
|
878
1117
|
const state = runState.get(ctx);
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1118
|
+
let terminal = false;
|
|
1119
|
+
try {
|
|
1120
|
+
terminal = info.cancelRequested === true || runs !== void 0 && await wasCancelRequested(runs, ctx.runId) || !detachableRun(ctx) && state?.interrupted !== true;
|
|
1121
|
+
if (terminal) await abortRun(runs, ctx.runId, state?.usage);
|
|
1122
|
+
} finally {
|
|
1123
|
+
if (terminal) state?.completion?.reject(info.reason);
|
|
882
1124
|
}
|
|
883
1125
|
}
|
|
884
1126
|
});
|