@dbos-inc/vercel-ai 0.3.7 → 0.5.4
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/README.md +84 -39
- package/dist/durable-stream.d.ts +7 -3
- package/dist/durable-stream.d.ts.map +1 -1
- package/dist/durable-stream.js +133 -23
- package/dist/durable-stream.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +9 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +26 -0
- package/dist/internal.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +16 -6
- package/dist/mcp.js.map +1 -1
- package/dist/middleware.d.ts +5 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +47 -17
- package/dist/middleware.js.map +1 -1
- package/dist/tools.d.ts +5 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +123 -18
- package/dist/tools.js.map +1 -1
- package/package.json +3 -3
- package/src/durable-stream.ts +139 -25
- package/src/index.ts +1 -1
- package/src/internal.ts +25 -0
- package/src/mcp.ts +14 -7
- package/src/middleware.ts +53 -21
- package/src/tools.ts +137 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dbos-inc/vercel-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "DBOS durable execution integration for the Vercel AI SDK",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"agent"
|
|
45
45
|
],
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@dbos-inc/dbos-sdk": "^4.
|
|
47
|
+
"@dbos-inc/dbos-sdk": "^4.27.0 || ^5.0.0",
|
|
48
48
|
"ai": "^7.0.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@ai-sdk/gateway": "^4.0.0",
|
|
52
52
|
"@ai-sdk/provider": "^4.0.0",
|
|
53
53
|
"@arethetypeswrong/cli": "^0.18.0",
|
|
54
|
-
"@dbos-inc/dbos-sdk": "^5.
|
|
54
|
+
"@dbos-inc/dbos-sdk": "^5.2.11",
|
|
55
55
|
"@types/node": "^24.0.0",
|
|
56
56
|
"@types/pg": "^8.20.0",
|
|
57
57
|
"ai": "^7.0.0",
|
package/src/durable-stream.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { DBOS, Error as DBOSErrors, StatusString } from '@dbos-inc/dbos-sdk';
|
|
3
3
|
import type { UIMessageChunk } from 'ai' with { 'resolution-mode': 'import' };
|
|
4
4
|
import type { LanguageModelV4FinishReason, LanguageModelV4StreamPart } from '@ai-sdk/provider' with { 'resolution-mode': 'import' };
|
|
@@ -10,7 +10,7 @@ export type DurableStreamOptions = string | { key: string; maxBatchParts?: numbe
|
|
|
10
10
|
export type DurableStreamRecord =
|
|
11
11
|
| { kind: 'model'; step: number; attempt: string; parts: LanguageModelV4StreamPart[] }
|
|
12
12
|
| { kind: 'model-end'; step: number; attempt: string; finishReason?: LanguageModelV4FinishReason; aborted?: true }
|
|
13
|
-
| { kind: 'tool'; step: number; attempt: number; toolCallId: string; output?: unknown; errorText?: string }
|
|
13
|
+
| { kind: 'tool'; step: number; attempt: number; toolCallId: string; output?: unknown; errorText?: string; chunks?: UIMessageChunk[] }
|
|
14
14
|
| { kind: 'ui'; step?: number; attempt?: number; chunks: UIMessageChunk[] }
|
|
15
15
|
| { kind: 'end'; finishReason: string };
|
|
16
16
|
|
|
@@ -30,17 +30,24 @@ function stepInfo(): { step: number; attempt: number } {
|
|
|
30
30
|
return { step: DBOS.stepID ?? -1, attempt: DBOS.stepStatus?.currentAttempt ?? 1 };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
//
|
|
33
|
+
// Only the parts toUIChunk renders; anything else would be written and never read.
|
|
34
34
|
function isContentPart(part: LanguageModelV4StreamPart): boolean {
|
|
35
35
|
switch (part.type) {
|
|
36
|
-
case '
|
|
37
|
-
case '
|
|
38
|
-
case '
|
|
39
|
-
case '
|
|
40
|
-
case '
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
case 'text-start':
|
|
37
|
+
case 'text-delta':
|
|
38
|
+
case 'text-end':
|
|
39
|
+
case 'reasoning-start':
|
|
40
|
+
case 'reasoning-delta':
|
|
41
|
+
case 'reasoning-end':
|
|
42
|
+
case 'tool-input-start':
|
|
43
|
+
case 'tool-input-delta':
|
|
44
|
+
case 'tool-call':
|
|
45
|
+
case 'tool-result':
|
|
46
|
+
case 'source':
|
|
47
|
+
case 'file':
|
|
43
48
|
return true;
|
|
49
|
+
default:
|
|
50
|
+
return false;
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
|
|
@@ -116,9 +123,14 @@ export class ModelStreamWriter {
|
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
/** Records a tool call's outcome from inside its step. */
|
|
120
|
-
export function writeToolRecord(
|
|
121
|
-
|
|
126
|
+
/** Records a tool call's outcome, with the message chunks it wrote, from inside its step. */
|
|
127
|
+
export function writeToolRecord(
|
|
128
|
+
key: string,
|
|
129
|
+
toolCallId: string,
|
|
130
|
+
outcome: { output: unknown } | { errorText: string },
|
|
131
|
+
chunks: UIMessageChunk[] = [],
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const record: DurableStreamRecord = { kind: 'tool', ...stepInfo(), toolCallId, ...outcome, ...(chunks.length > 0 && { chunks }) };
|
|
122
134
|
return DBOS.writeStream(key, record);
|
|
123
135
|
}
|
|
124
136
|
|
|
@@ -151,7 +163,10 @@ export interface ReadDurableStreamOptions {
|
|
|
151
163
|
key: string;
|
|
152
164
|
/** Id for the `start` chunk; omitted on a resume (`offset` > 0). */
|
|
153
165
|
messageId?: string;
|
|
154
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Number of records already consumed, from the last `data-dbos-offset` chunk. A resume from mid-text can't be applied
|
|
168
|
+
* to a partial message by the AI SDK (it has no record of the open part); to rebuild a message, read from 0 instead.
|
|
169
|
+
*/
|
|
155
170
|
offset?: number;
|
|
156
171
|
/** Defaults to `DBOS`; pass a `DBOSClient` to read from a process that has not launched DBOS. */
|
|
157
172
|
client?: DurableStreamSource;
|
|
@@ -183,11 +198,13 @@ async function* uiChunks(options: ReadDurableStreamOptions): AsyncGenerator<UIMe
|
|
|
183
198
|
const client: DurableStreamSource = options.client ?? DBOS;
|
|
184
199
|
const state: ReaderState = {
|
|
185
200
|
offset: options.offset ?? 0,
|
|
186
|
-
resumed: (options.offset ?? 0) > 0,
|
|
187
201
|
openParts: new Map(),
|
|
202
|
+
detachedParts: new Set(),
|
|
203
|
+
toolChunksSent: new Map(),
|
|
188
204
|
ended: false,
|
|
189
205
|
};
|
|
190
206
|
if (state.offset === 0) yield { type: 'start', messageId: options.messageId };
|
|
207
|
+
else await restoreOpenCall(client, workflowID, key, state);
|
|
191
208
|
const emit = (record: DurableStreamRecord) => emitRecord(state, record, { sendReasoning, sendSources, onError });
|
|
192
209
|
|
|
193
210
|
// Phase 1: everything already stored, one value per query until an offset is empty; a superseded attempt is skipped whole.
|
|
@@ -201,12 +218,16 @@ async function* uiChunks(options: ReadDurableStreamOptions): AsyncGenerator<UIMe
|
|
|
201
218
|
}
|
|
202
219
|
}
|
|
203
220
|
const finalAttempt = new Map<number, string>();
|
|
204
|
-
|
|
221
|
+
// A re-executed tool call writes another record; the last one matches its checkpoint, so only its chunks (if any) are shown.
|
|
222
|
+
const finalToolRecord = new Map<string, number>();
|
|
223
|
+
history.forEach((record, index) => {
|
|
205
224
|
if (record.kind === 'model' || record.kind === 'model-end') finalAttempt.set(record.step, record.attempt);
|
|
206
|
-
|
|
207
|
-
|
|
225
|
+
if (record.kind === 'tool') finalToolRecord.set(toolKey(record), index);
|
|
226
|
+
});
|
|
227
|
+
for (const [index, record] of history.entries()) {
|
|
208
228
|
const stale = (record.kind === 'model' || record.kind === 'model-end') && finalAttempt.get(record.step) !== record.attempt;
|
|
209
|
-
|
|
229
|
+
const staleChunks = record.kind === 'tool' && record.chunks !== undefined && finalToolRecord.get(toolKey(record)) !== index;
|
|
230
|
+
yield* stale ? skipRecord(state) : emit(staleChunks ? { ...record, chunks: undefined } : record);
|
|
210
231
|
if (state.ended) return;
|
|
211
232
|
}
|
|
212
233
|
|
|
@@ -233,15 +254,23 @@ async function* uiChunks(options: ReadDurableStreamOptions): AsyncGenerator<UIMe
|
|
|
233
254
|
|
|
234
255
|
interface ReaderState {
|
|
235
256
|
offset: number;
|
|
236
|
-
resumed: boolean;
|
|
237
257
|
openStep?: number;
|
|
238
258
|
openAttempt?: string;
|
|
239
259
|
// Text/reasoning parts of the open attempt that have started but not ended, by UI part id.
|
|
240
260
|
openParts: Map<string, 'text' | 'reasoning'>;
|
|
261
|
+
// Open parts from before a resume: the client's resumed stream no longer tracks them, so they are never ended.
|
|
262
|
+
detachedParts: Set<string>;
|
|
263
|
+
// Digest of the chunks emitted for each tool call (by toolKey).
|
|
264
|
+
toolChunksSent: Map<string, string>;
|
|
241
265
|
finishReason?: string;
|
|
242
266
|
ended: boolean;
|
|
243
267
|
}
|
|
244
268
|
|
|
269
|
+
// A step's function id survives re-execution, and with it tell apart calls that reuse a provider's tool call id.
|
|
270
|
+
function toolKey(record: Extract<DurableStreamRecord, { kind: 'tool' }>): string {
|
|
271
|
+
return `${record.step}:${record.toolCallId}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
245
274
|
function offsetChunk(state: ReaderState): UIMessageChunk {
|
|
246
275
|
return { type: 'data-dbos-offset', data: { offset: state.offset }, transient: true } as UIMessageChunk;
|
|
247
276
|
}
|
|
@@ -256,17 +285,90 @@ function* closeStep(state: ReaderState): Generator<UIMessageChunk> {
|
|
|
256
285
|
state.openStep = undefined;
|
|
257
286
|
state.openAttempt = undefined;
|
|
258
287
|
state.openParts.clear();
|
|
288
|
+
state.detachedParts.clear();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Records outside the open model call a resume reads past before giving up; a reconnect mid-call finds it at once.
|
|
292
|
+
const RESTORE_SCAN_LIMIT = 100;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* On resume, rebuild the model call that was open at `offset` by reading back to its start, so the reader continues
|
|
296
|
+
* exactly as an uninterrupted one would: a re-executed attempt is superseded, and step boundaries fall in the same place.
|
|
297
|
+
* Past RESTORE_SCAN_LIMIT unrelated records it gives up, and the resumed stream only lacks that step's `finish-step`.
|
|
298
|
+
*/
|
|
299
|
+
async function restoreOpenCall(client: DurableStreamSource, workflowID: string, key: string, state: ReaderState): Promise<void> {
|
|
300
|
+
let step: number | undefined;
|
|
301
|
+
let attempt: string | undefined;
|
|
302
|
+
// Set once an earlier step's record is reached: the open call is fully read, and only a finish reason is still wanted.
|
|
303
|
+
let callRead = false;
|
|
304
|
+
let budget = RESTORE_SCAN_LIMIT;
|
|
305
|
+
const ended = new Set<string>();
|
|
306
|
+
for (let index = state.offset - 1; index >= 0; index--) {
|
|
307
|
+
let record: DurableStreamRecord;
|
|
308
|
+
try {
|
|
309
|
+
record = await client.readStreamOffset<DurableStreamRecord>(workflowID, key, index, { timeoutSeconds: 0 });
|
|
310
|
+
} catch (error) {
|
|
311
|
+
// A real offset never passes the stored records, so a gap means there is nothing to restore.
|
|
312
|
+
if (DBOSErrors.isStreamTimeoutError(error)) break;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
if (record.kind === 'end') break;
|
|
316
|
+
const model = record.kind === 'model' || record.kind === 'model-end' ? record : undefined;
|
|
317
|
+
if (model && step === undefined) {
|
|
318
|
+
step = model.step;
|
|
319
|
+
attempt = model.attempt;
|
|
320
|
+
}
|
|
321
|
+
if (model && !callRead && model.step === step) {
|
|
322
|
+
if (model.attempt === attempt) restorePart(state, ended, model);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (model) callRead = true;
|
|
326
|
+
// As in an uninterrupted read, the finish reason carries over from the latest earlier call that ended.
|
|
327
|
+
if (callRead && (state.finishReason !== undefined || model?.kind === 'model-end')) {
|
|
328
|
+
if (model?.kind === 'model-end') state.finishReason ??= finishReasonOf(model);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
if (--budget === 0) break;
|
|
332
|
+
}
|
|
333
|
+
if (step === undefined) return;
|
|
334
|
+
state.openStep = step;
|
|
335
|
+
state.openAttempt = attempt;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function finishReasonOf(record: Extract<DurableStreamRecord, { kind: 'model-end' }>): string | undefined {
|
|
339
|
+
return record.aborted ? 'other' : record.finishReason?.unified;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// One record of the open call, read backward: a part is open if its start comes before (i.e. is read after) any end.
|
|
343
|
+
function restorePart(state: ReaderState, ended: Set<string>, record: Extract<DurableStreamRecord, { kind: 'model' | 'model-end' }>): void {
|
|
344
|
+
if (record.kind === 'model-end') {
|
|
345
|
+
state.finishReason ??= finishReasonOf(record);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
for (const part of [...record.parts].reverse()) {
|
|
349
|
+
const kind = part.type === 'text-start' || part.type === 'text-end' ? 'text' : part.type === 'reasoning-start' || part.type === 'reasoning-end' ? 'reasoning' : undefined;
|
|
350
|
+
if (kind === undefined || !('id' in part)) continue;
|
|
351
|
+
const id = `${record.attempt}:${part.id}`;
|
|
352
|
+
if (part.type.endsWith('-end')) ended.add(id);
|
|
353
|
+
else if (!ended.has(id)) {
|
|
354
|
+
state.openParts.set(id, kind);
|
|
355
|
+
state.detachedParts.add(id);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
259
358
|
}
|
|
260
359
|
|
|
261
360
|
// A live re-execution of the open step: end the stale attempt's parts and tell the client which ones to discard.
|
|
262
361
|
function* supersede(state: ReaderState, attempt: string): Generator<UIMessageChunk> {
|
|
263
|
-
for (const [id, kind] of state.openParts)
|
|
362
|
+
for (const [id, kind] of state.openParts) {
|
|
363
|
+
if (!state.detachedParts.has(id)) yield { type: kind === 'text' ? 'text-end' : 'reasoning-end', id };
|
|
364
|
+
}
|
|
264
365
|
yield {
|
|
265
366
|
type: 'data-dbos-superseded',
|
|
266
367
|
data: { attempt: state.openAttempt, parts: [...state.openParts.keys()] },
|
|
267
368
|
transient: true,
|
|
268
369
|
} as UIMessageChunk;
|
|
269
370
|
state.openParts.clear();
|
|
371
|
+
state.detachedParts.clear();
|
|
270
372
|
state.openAttempt = attempt;
|
|
271
373
|
}
|
|
272
374
|
|
|
@@ -280,8 +382,7 @@ function* emitRecord(
|
|
|
280
382
|
case 'model': {
|
|
281
383
|
if (state.openStep !== record.step) {
|
|
282
384
|
yield* closeStep(state);
|
|
283
|
-
|
|
284
|
-
state.resumed = false;
|
|
385
|
+
yield { type: 'start-step' };
|
|
285
386
|
state.openStep = record.step;
|
|
286
387
|
state.openAttempt = record.attempt;
|
|
287
388
|
} else if (state.openAttempt !== record.attempt) {
|
|
@@ -300,14 +401,27 @@ function* emitRecord(
|
|
|
300
401
|
}
|
|
301
402
|
case 'model-end':
|
|
302
403
|
// The stream outlives the call: the workflow may run more calls, so only its end (or closeDurableStream) ends the turn.
|
|
303
|
-
if (record.attempt === state.openAttempt) state.finishReason = record
|
|
404
|
+
if (record.attempt === state.openAttempt) state.finishReason = finishReasonOf(record);
|
|
304
405
|
break;
|
|
305
|
-
case 'tool':
|
|
406
|
+
case 'tool': {
|
|
407
|
+
const key = toolKey(record);
|
|
408
|
+
const chunks = record.chunks ?? [];
|
|
409
|
+
const sent = state.toolChunksSent.get(key);
|
|
410
|
+
// A live re-execution that wrote the same chunks changes nothing; different ones (or none) replace what the client has.
|
|
411
|
+
if (chunks.length > 0 || sent !== undefined) {
|
|
412
|
+
const digest = createHash('sha256').update(JSON.stringify(chunks)).digest('base64');
|
|
413
|
+
if (sent !== digest) {
|
|
414
|
+
if (sent !== undefined) yield { type: 'data-dbos-tool-superseded', data: { toolCallId: record.toolCallId }, transient: true } as UIMessageChunk;
|
|
415
|
+
state.toolChunksSent.set(key, digest);
|
|
416
|
+
yield* chunks;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
306
419
|
// Local tool errors are masked like the AI SDK does; provider-executed ones (in model records) pass through verbatim.
|
|
307
420
|
yield record.errorText !== undefined
|
|
308
421
|
? { type: 'tool-output-error', toolCallId: record.toolCallId, errorText: filter.onError(new Error(record.errorText)) }
|
|
309
422
|
: { type: 'tool-output-available', toolCallId: record.toolCallId, output: record.output };
|
|
310
423
|
break;
|
|
424
|
+
}
|
|
311
425
|
case 'ui':
|
|
312
426
|
yield* record.chunks;
|
|
313
427
|
break;
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { durableCalls, DurableCallsOptions, durableEmbeddingCalls, durableImageCalls } from './middleware';
|
|
2
2
|
export { durableMCPTools, DurableMCPToolsOptions, MCPClientLike } from './mcp';
|
|
3
|
-
export { durableTools, DurableToolsOptions } from './tools';
|
|
3
|
+
export { durableTools, DurableToolsOptions, toolWriter } from './tools';
|
|
4
4
|
export {
|
|
5
5
|
closeDurableStream,
|
|
6
6
|
DurableStreamOptions,
|
package/src/internal.ts
CHANGED
|
@@ -21,6 +21,31 @@ export function runDurableStep<T>(name: string, fn: () => Promise<T>, config: St
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// DBOS 5.1+ fires stepStatus.cancelSignal when the step's workflow is cancelled; older versions have none.
|
|
25
|
+
export function stepCancelSignal(): AbortSignal | undefined {
|
|
26
|
+
return (DBOS.stepStatus as { cancelSignal?: AbortSignal } | undefined)?.cancelSignal;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* DBOS discards a timed-out attempt's result, so a stream record of it would contradict the checkpoint. Returns the
|
|
31
|
+
* outcome to record instead: the timeout when the step ends with it, `null` when a retry follows, `undefined` if not timed out.
|
|
32
|
+
*/
|
|
33
|
+
export async function timedOutOutcome(shouldRetry: StepConfig['shouldRetry']): Promise<{ errorText: string } | null | undefined> {
|
|
34
|
+
const status = DBOS.stepStatus;
|
|
35
|
+
if (status?.timeoutSignal?.aborted !== true) return undefined;
|
|
36
|
+
const reason: unknown = status.timeoutSignal.reason;
|
|
37
|
+
const lastAttempt = status.currentAttempt === undefined || status.currentAttempt >= (status.maxAttempts ?? 1);
|
|
38
|
+
// DBOS asks the step's shouldRetry about this same error (the signal's reason) before retrying; a throw ends the step.
|
|
39
|
+
const retried = !lastAttempt && (await Promise.resolve(shouldRetry ? shouldRetry(reason) : true).catch(() => false));
|
|
40
|
+
return retried ? null : { errorText: reason instanceof Error ? reason.message : 'The step timed out.' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fires when any given signal does; a lone signal is returned as-is.
|
|
44
|
+
export function anySignal(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
|
|
45
|
+
const defined = signals.filter((s): s is AbortSignal => s !== undefined);
|
|
46
|
+
return defined.length <= 1 ? defined[0] : AbortSignal.any(defined);
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
export function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
25
50
|
return typeof (value as AsyncIterable<unknown> | null | undefined)?.[Symbol.asyncIterator] === 'function';
|
|
26
51
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { StepConfig } from '@dbos-inc/dbos-sdk';
|
|
1
|
+
import { DBOS, StepConfig } from '@dbos-inc/dbos-sdk';
|
|
2
2
|
import type { ToolSet } from 'ai' with { 'resolution-mode': 'import' };
|
|
3
|
-
import { isAsyncIterable, runDurableStep, withErrorClassification } from './internal';
|
|
3
|
+
import { anySignal, isAsyncIterable, runDurableStep, stepCancelSignal, timedOutOutcome, withErrorClassification } from './internal';
|
|
4
4
|
import { writeToolRecord } from './durable-stream';
|
|
5
5
|
|
|
6
6
|
// Structural type for an MCP client (e.g. from @ai-sdk/mcp) — deliberately loose: the AI SDK ecosystem
|
|
@@ -112,11 +112,20 @@ export async function durableMCPTools(client: MCPClientLike, options: DurableMCP
|
|
|
112
112
|
return run(
|
|
113
113
|
`mcp.tool.${name}.${toolCallId ?? 'call'}`,
|
|
114
114
|
async () => {
|
|
115
|
+
// Stop the call when the attempt times out or the workflow is cancelled, as well as on the caller's abort.
|
|
116
|
+
const cancelSignal = stepCancelSignal();
|
|
117
|
+
const record = async (outcome: { output: unknown } | { errorText: string }) => {
|
|
118
|
+
if (!durableStream || !toolCallId) return;
|
|
119
|
+
const timeout = await timedOutOutcome(callConfig.shouldRetry);
|
|
120
|
+
if (timeout === null) return;
|
|
121
|
+
await writeToolRecord(durableStream, toolCallId, timeout ?? outcome);
|
|
122
|
+
};
|
|
123
|
+
const abortSignal = anySignal(signal, DBOS.stepStatus?.timeoutSignal, cancelSignal);
|
|
115
124
|
let output: unknown;
|
|
116
125
|
try {
|
|
117
126
|
const tool = (await client.tools(toolOptions))[name] as MCPToolLike | undefined;
|
|
118
127
|
if (typeof tool?.execute !== 'function') throw new Error(`MCP tool "${name}" is not executable.`);
|
|
119
|
-
output = await tool.execute(input, execOptions);
|
|
128
|
+
output = await tool.execute(input, abortSignal === signal ? execOptions : { ...execOptions, abortSignal });
|
|
120
129
|
// A streaming execute can't checkpoint mid-flight; drain it and record the final value (the last yield).
|
|
121
130
|
if (isAsyncIterable(output)) {
|
|
122
131
|
let last: unknown;
|
|
@@ -124,12 +133,10 @@ export async function durableMCPTools(client: MCPClientLike, options: DurableMCP
|
|
|
124
133
|
output = last;
|
|
125
134
|
}
|
|
126
135
|
} catch (error) {
|
|
127
|
-
if (
|
|
128
|
-
await writeToolRecord(durableStream, toolCallId, { errorText: error instanceof Error ? error.message : String(error) });
|
|
129
|
-
}
|
|
136
|
+
if (!cancelSignal?.aborted) await record({ errorText: error instanceof Error ? error.message : String(error) });
|
|
130
137
|
throw error;
|
|
131
138
|
}
|
|
132
|
-
|
|
139
|
+
await record({ output });
|
|
133
140
|
return output;
|
|
134
141
|
},
|
|
135
142
|
callConfig,
|
package/src/middleware.ts
CHANGED
|
@@ -20,12 +20,14 @@ import type {
|
|
|
20
20
|
SharedV4ProviderMetadata,
|
|
21
21
|
SharedV4Warning,
|
|
22
22
|
} from '@ai-sdk/provider' with { 'resolution-mode': 'import' };
|
|
23
|
-
import { assertNotInTransaction, isInWorkflowFunction, restoreAISDKErrorIdentity, withErrorClassification } from './internal';
|
|
23
|
+
import { anySignal, assertNotInTransaction, isInWorkflowFunction, restoreAISDKErrorIdentity, stepCancelSignal, withErrorClassification } from './internal';
|
|
24
24
|
import { type DurableStreamOptions, ModelStreamWriter, resolveDurableStream } from './durable-stream';
|
|
25
25
|
|
|
26
26
|
export interface DurableCallsOptions extends StepConfig {
|
|
27
27
|
/** Write each streamed model call's parts to this durable stream from inside its step (see readDurableStream). */
|
|
28
28
|
durableStream?: DurableStreamOptions;
|
|
29
|
+
/** Checkpoint a generate call's raw provider request/response bodies; set to match generateText's `include` (both default false). */
|
|
30
|
+
include?: { requestBody?: boolean; responseBody?: boolean };
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// In-flight durable model calls per workflow; concurrent calls have a nondeterministic DBOS step order on replay, so we reject them.
|
|
@@ -58,7 +60,7 @@ function exitDurableModelCall(workflowID: string): void {
|
|
|
58
60
|
|
|
59
61
|
/** AI SDK language-model middleware that runs each model call as a durable, checkpointed DBOS step (replayed on recovery); outside a workflow it calls the model directly. */
|
|
60
62
|
export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMiddleware {
|
|
61
|
-
const { durableStream, ...stepOptions } = options;
|
|
63
|
+
const { durableStream, include, ...stepOptions } = options;
|
|
62
64
|
const stepConfig = withErrorClassification(stepOptions);
|
|
63
65
|
const streamConfig = resolveDurableStream(durableStream);
|
|
64
66
|
return {
|
|
@@ -73,7 +75,10 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
|
|
|
73
75
|
try {
|
|
74
76
|
return await DBOS.runStep(
|
|
75
77
|
async () => {
|
|
76
|
-
|
|
78
|
+
// The provider call also stops when the attempt times out or the workflow is cancelled.
|
|
79
|
+
const signal = anySignal(params.abortSignal, DBOS.stepStatus?.timeoutSignal, stepCancelSignal());
|
|
80
|
+
const raw = signal === params.abortSignal ? await doGenerate() : await model.doGenerate({ ...params, abortSignal: signal });
|
|
81
|
+
const result = ensureResponseMetadata(encodeBinaryContent(omitBodies(raw, include)));
|
|
77
82
|
// A non-streaming call writes its whole output at once, so the stream holds every call the loop makes, not only streamed ones.
|
|
78
83
|
if (streamConfig) {
|
|
79
84
|
const streamWriter = new ModelStreamWriter(streamConfig);
|
|
@@ -155,22 +160,26 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
|
|
|
155
160
|
const accumulator = new StreamAccumulator();
|
|
156
161
|
// A timed-out attempt is abandoned by DBOS (its outcome is discarded) but keeps running; stop it so it can't emit alongside a retry.
|
|
157
162
|
const timeoutSignal = DBOS.stepStatus?.timeoutSignal;
|
|
158
|
-
|
|
163
|
+
// A workflow cancel is not a consumer abort: it must fail the attempt so nothing is checkpointed and a resume re-runs the call.
|
|
164
|
+
const cancelSignal = stepCancelSignal();
|
|
165
|
+
const cancelledWorkflow = () => cancelSignal?.aborted === true;
|
|
166
|
+
const providerSignal = anySignal(abortSignal, timeoutSignal, cancelSignal);
|
|
159
167
|
let reader: ReadableStreamDefaultReader<LanguageModelV4StreamPart> | undefined;
|
|
160
168
|
let sawFinish = false;
|
|
161
169
|
const abandon = () => void reader?.cancel().catch(() => {});
|
|
162
170
|
timeoutSignal?.addEventListener('abort', abandon, { once: true });
|
|
171
|
+
cancelSignal?.addEventListener('abort', abandon, { once: true });
|
|
163
172
|
// A consumer abort tears the provider call down too; the attempt is then recorded as aborted below.
|
|
164
173
|
abortSignal?.addEventListener('abort', abandon, { once: true });
|
|
165
174
|
// Step-scope stream writes: cheap, replay-safe, and never duplicated since a retry is refused once content has streamed.
|
|
166
175
|
const streamWriter = streamConfig ? new ModelStreamWriter(streamConfig) : undefined;
|
|
167
176
|
try {
|
|
168
|
-
|
|
169
|
-
reader =
|
|
177
|
+
const result = providerSignal === abortSignal ? await doStream() : await model.doStream({ ...params, abortSignal: providerSignal });
|
|
178
|
+
reader = result.stream.getReader();
|
|
170
179
|
for (;;) {
|
|
171
180
|
const { done, value: part } = await reader.read();
|
|
172
181
|
if (timeoutSignal?.aborted) throw (timeoutSignal.reason ?? new Error('step attempt timed out'));
|
|
173
|
-
if (done || aborted()) break;
|
|
182
|
+
if (done || aborted() || cancelledWorkflow()) break;
|
|
174
183
|
if (part.type === 'error') {
|
|
175
184
|
// A cancelled consumer abandoned this call; don't let a late failure become the step outcome, or replay would fail where the live run succeeded.
|
|
176
185
|
if (cancelled) break;
|
|
@@ -183,21 +192,26 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
|
|
|
183
192
|
accumulator.add(part);
|
|
184
193
|
}
|
|
185
194
|
// No terminal part and no output: fail (retryably) like the AI SDK's NoOutputGeneratedError, instead of checkpointing a permanent empty success.
|
|
186
|
-
if (!sawFinish && !accumulator.hasContent && !cancelled && !aborted()) {
|
|
195
|
+
if (!sawFinish && !accumulator.hasContent && !cancelled && !aborted() && !cancelledWorkflow()) {
|
|
187
196
|
throw new Error('Model stream ended without a finish part or any output.');
|
|
188
197
|
}
|
|
189
198
|
} catch (error) {
|
|
190
|
-
// Same rule for stream-level failures (doStream or a read rejecting) after a cancel; after an abort,
|
|
191
|
-
if (!cancelled && !aborted()) {
|
|
199
|
+
// Same rule for stream-level failures (doStream or a read rejecting) after a cancel; after an abort or a workflow cancel, that is the outcome.
|
|
200
|
+
if (!cancelled && !aborted() && !cancelledWorkflow()) {
|
|
192
201
|
await streamWriter?.abandon();
|
|
193
202
|
throw error;
|
|
194
203
|
}
|
|
195
204
|
} finally {
|
|
196
205
|
timeoutSignal?.removeEventListener('abort', abandon);
|
|
206
|
+
cancelSignal?.removeEventListener('abort', abandon);
|
|
197
207
|
abortSignal?.removeEventListener('abort', abandon);
|
|
198
208
|
// Tear down the provider stream on early exits (error part, post-cancel break); a no-op after a clean drain.
|
|
199
209
|
void reader?.cancel().catch(() => {});
|
|
200
210
|
}
|
|
211
|
+
if (cancelledWorkflow()) {
|
|
212
|
+
await streamWriter?.abandon();
|
|
213
|
+
throw cancelSignal!.reason;
|
|
214
|
+
}
|
|
201
215
|
// Record the abort as the step's failure; replay rethrows it, so the workflow must catch aborts it means to survive.
|
|
202
216
|
if (aborted()) {
|
|
203
217
|
await streamWriter?.end({ aborted: true });
|
|
@@ -208,7 +222,7 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
|
|
|
208
222
|
// Skip the live emit for a timed-out (abandoned) attempt so it can't interleave with its retry.
|
|
209
223
|
const responseMetadataPart = accumulator.fillResponseMetadata(randomUUID(), new Date());
|
|
210
224
|
if (responseMetadataPart && !timeoutSignal?.aborted) emit(responseMetadataPart);
|
|
211
|
-
const recorded = encodeBinaryContent(accumulator.result(
|
|
225
|
+
const recorded = encodeBinaryContent(accumulator.result());
|
|
212
226
|
// Every stream write lands before the checkpoint, so a reader that sees the next step has seen all of this one.
|
|
213
227
|
await streamWriter?.end({ finishReason: recorded.finishReason });
|
|
214
228
|
return recorded;
|
|
@@ -272,14 +286,17 @@ export function durableEmbeddingCalls(options: StepConfig = {}): EmbeddingModelM
|
|
|
272
286
|
then: (onfulfilled, onrejected) =>
|
|
273
287
|
Promise.resolve(isInWorkflowFunction() ? false : model.supportsParallelCalls).then(onfulfilled, onrejected),
|
|
274
288
|
}),
|
|
275
|
-
wrapEmbed: async ({ doEmbed, model }) => {
|
|
289
|
+
wrapEmbed: async ({ doEmbed, params, model }) => {
|
|
276
290
|
assertNotInTransaction('embed');
|
|
277
291
|
if (!isInWorkflowFunction()) {
|
|
278
292
|
return await doEmbed();
|
|
279
293
|
}
|
|
280
294
|
const workflowID = await enterDurableModelCall();
|
|
281
295
|
try {
|
|
282
|
-
return await DBOS.runStep(async () =>
|
|
296
|
+
return await DBOS.runStep(async () => {
|
|
297
|
+
const signal = anySignal(params.abortSignal, DBOS.stepStatus?.timeoutSignal, stepCancelSignal());
|
|
298
|
+
return signal === params.abortSignal ? doEmbed() : model.doEmbed({ ...params, abortSignal: signal });
|
|
299
|
+
}, {
|
|
283
300
|
...stepConfig,
|
|
284
301
|
name: stepConfig.name ?? stepName(model, 'embed'),
|
|
285
302
|
});
|
|
@@ -301,13 +318,16 @@ export function durableImageCalls(options: StepConfig = {}): ImageModelMiddlewar
|
|
|
301
318
|
const stepConfig = withErrorClassification(options);
|
|
302
319
|
return {
|
|
303
320
|
specificationVersion: 'v4',
|
|
304
|
-
wrapGenerate: async ({ doGenerate, model }) => {
|
|
321
|
+
wrapGenerate: async ({ doGenerate, params, model }) => {
|
|
305
322
|
assertNotInTransaction('generateImage');
|
|
306
323
|
if (!isInWorkflowFunction()) {
|
|
307
324
|
return await doGenerate();
|
|
308
325
|
}
|
|
309
326
|
try {
|
|
310
|
-
return await DBOS.runStep(async () =>
|
|
327
|
+
return await DBOS.runStep(async () => {
|
|
328
|
+
const signal = anySignal(params.abortSignal, DBOS.stepStatus?.timeoutSignal, stepCancelSignal());
|
|
329
|
+
return encodeImageResult(signal === params.abortSignal ? await doGenerate() : await model.doGenerate({ ...params, abortSignal: signal }));
|
|
330
|
+
}, {
|
|
311
331
|
...stepConfig,
|
|
312
332
|
name: stepConfig.name ?? stepName(model, 'image'),
|
|
313
333
|
});
|
|
@@ -371,6 +391,21 @@ function ensureResponseMetadata(result: LanguageModelV4GenerateResult): Language
|
|
|
371
391
|
};
|
|
372
392
|
}
|
|
373
393
|
|
|
394
|
+
// generateText discards the raw bodies unless `include` asks for them, and the request body repeats the whole prompt.
|
|
395
|
+
function omitBodies(
|
|
396
|
+
result: LanguageModelV4GenerateResult,
|
|
397
|
+
include: DurableCallsOptions['include'],
|
|
398
|
+
): LanguageModelV4GenerateResult {
|
|
399
|
+
const dropRequest = !include?.requestBody && result.request?.body !== undefined;
|
|
400
|
+
const dropResponse = !include?.responseBody && result.response?.body !== undefined;
|
|
401
|
+
if (!dropRequest && !dropResponse) return result;
|
|
402
|
+
return {
|
|
403
|
+
...result,
|
|
404
|
+
request: dropRequest ? { ...result.request, body: undefined } : result.request,
|
|
405
|
+
response: dropResponse ? { ...result.response, body: undefined } : result.response,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
374
409
|
/** Convert generated-file bytes (Uint8Array) to base64 (spec-allowed) to keep checkpoints compact. */
|
|
375
410
|
function encodeBinaryContent(result: LanguageModelV4GenerateResult): LanguageModelV4GenerateResult {
|
|
376
411
|
const content = result.content.map(encodeBinaryPart);
|
|
@@ -515,18 +550,15 @@ class StreamAccumulator {
|
|
|
515
550
|
return block;
|
|
516
551
|
}
|
|
517
552
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
response?: { headers?: Record<string, string> },
|
|
521
|
-
): LanguageModelV4GenerateResult {
|
|
553
|
+
// Omits the provider's request/response (wrapStream returns only the stream): the request body repeats the whole prompt.
|
|
554
|
+
result(): LanguageModelV4GenerateResult {
|
|
522
555
|
return {
|
|
523
556
|
content: this.content,
|
|
524
557
|
finishReason: this.finishReason,
|
|
525
558
|
usage: this.usage,
|
|
526
559
|
warnings: this.warnings,
|
|
527
560
|
providerMetadata: this.providerMetadata,
|
|
528
|
-
|
|
529
|
-
response: this.responseMetadata ? { ...this.responseMetadata, ...response } : response,
|
|
561
|
+
response: this.responseMetadata,
|
|
530
562
|
};
|
|
531
563
|
}
|
|
532
564
|
}
|