@dbos-inc/vercel-ai 0.2.5 → 0.3.7
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 +110 -87
- package/dist/agent-tool.d.ts +42 -0
- package/dist/agent-tool.d.ts.map +1 -0
- package/dist/agent-tool.js +92 -0
- package/dist/agent-tool.js.map +1 -0
- package/dist/durable-stream.d.ts +112 -0
- package/dist/durable-stream.d.ts.map +1 -0
- package/dist/durable-stream.js +344 -0
- package/dist/durable-stream.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +15 -0
- package/dist/internal.js.map +1 -1
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +27 -26
- package/dist/mcp.js.map +1 -1
- package/dist/middleware.d.ts +6 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +94 -61
- package/dist/middleware.js.map +1 -1
- package/dist/tools.d.ts +15 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +78 -0
- package/dist/tools.js.map +1 -0
- package/package.json +1 -1
- package/src/agent-tool.ts +122 -0
- package/src/durable-stream.ts +388 -0
- package/src/index.ts +12 -1
- package/src/internal.ts +14 -0
- package/src/mcp.ts +28 -25
- package/src/middleware.ts +102 -66
- package/src/tools.ts +86 -0
package/src/middleware.ts
CHANGED
|
@@ -21,24 +21,26 @@ import type {
|
|
|
21
21
|
SharedV4Warning,
|
|
22
22
|
} from '@ai-sdk/provider' with { 'resolution-mode': 'import' };
|
|
23
23
|
import { assertNotInTransaction, isInWorkflowFunction, restoreAISDKErrorIdentity, withErrorClassification } from './internal';
|
|
24
|
+
import { type DurableStreamOptions, ModelStreamWriter, resolveDurableStream } from './durable-stream';
|
|
25
|
+
|
|
26
|
+
export interface DurableCallsOptions extends StepConfig {
|
|
27
|
+
/** Write each streamed model call's parts to this durable stream from inside its step (see readDurableStream). */
|
|
28
|
+
durableStream?: DurableStreamOptions;
|
|
29
|
+
}
|
|
24
30
|
|
|
25
31
|
// In-flight durable model calls per workflow; concurrent calls have a nondeterministic DBOS step order on replay, so we reject them.
|
|
26
32
|
const inflightModelCalls = new Map<string, number>();
|
|
33
|
+
// A stream call whose consumer detached (abort/cancel) but whose step is still settling; the next call in that workflow waits for it.
|
|
34
|
+
const settlingModelCalls = new Map<string, Promise<void>>();
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
const detachedAbortSignals = new WeakMap<object, AbortSignal>();
|
|
30
|
-
|
|
31
|
-
function enterDurableModelCall(operation: 'generate' | 'stream' | 'embed'): string {
|
|
36
|
+
async function enterDurableModelCall(): Promise<string> {
|
|
32
37
|
const workflowID = DBOS.workflowID!;
|
|
38
|
+
// Sequence after a detached call's checkpoint, so steps are always recorded in the order they were started.
|
|
39
|
+
await settlingModelCalls.get(workflowID);
|
|
33
40
|
const inflight = inflightModelCalls.get(workflowID) ?? 0;
|
|
34
41
|
if (inflight > 0) {
|
|
35
|
-
// embedMany parallelizes its batches; maxParallelCalls: 1 serializes them deterministically. Other callers use child workflows.
|
|
36
|
-
const remedy =
|
|
37
|
-
operation === 'embed'
|
|
38
|
-
? 'pass maxParallelCalls: 1 to embedMany, or run each call in its own child workflow with DBOS.startWorkflow'
|
|
39
|
-
: 'run each call in its own child workflow with DBOS.startWorkflow';
|
|
40
42
|
throw new Error(
|
|
41
|
-
`Concurrent durable model calls in workflow "${workflowID}" are not supported because their step order is nondeterministic on replay;
|
|
43
|
+
`Concurrent durable model calls in workflow "${workflowID}" are not supported because their step order is nondeterministic on replay; run each call in its own child workflow with DBOS.startWorkflow.`,
|
|
42
44
|
);
|
|
43
45
|
}
|
|
44
46
|
inflightModelCalls.set(workflowID, inflight + 1);
|
|
@@ -55,35 +57,36 @@ function exitDurableModelCall(workflowID: string): void {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/** 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. */
|
|
58
|
-
export function durableCalls(options:
|
|
59
|
-
const
|
|
60
|
+
export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMiddleware {
|
|
61
|
+
const { durableStream, ...stepOptions } = options;
|
|
62
|
+
const stepConfig = withErrorClassification(stepOptions);
|
|
63
|
+
const streamConfig = resolveDurableStream(durableStream);
|
|
60
64
|
return {
|
|
61
65
|
specificationVersion: 'v4',
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
// the truncated output would be checkpointed as the step's permanent result and replayed on recovery as the whole
|
|
65
|
-
// answer. Withhold the signal from the provider so the call records a complete result; keep it here for the guard.
|
|
66
|
-
// Generate is left alone: an aborted doGenerate rejects (nothing partial to record), and the AI SDK's tool loop
|
|
67
|
-
// relies on that rejection to stop — stripping it there would let the loop keep calling the model after an abort.
|
|
68
|
-
transformParams: async ({ params, type }) => {
|
|
69
|
-
if (type !== 'stream' || !isInWorkflowFunction() || !params.abortSignal) {
|
|
70
|
-
return params;
|
|
71
|
-
}
|
|
72
|
-
const { abortSignal, ...rest } = params;
|
|
73
|
-
detachedAbortSignals.set(rest, abortSignal);
|
|
74
|
-
return rest;
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
wrapGenerate: async ({ doGenerate, model }) => {
|
|
67
|
+
wrapGenerate: async ({ doGenerate, params, model }) => {
|
|
78
68
|
assertNotInTransaction('generate');
|
|
79
69
|
if (!isInWorkflowFunction()) {
|
|
80
70
|
return await doGenerate();
|
|
81
71
|
}
|
|
82
|
-
const workflowID = enterDurableModelCall(
|
|
72
|
+
const workflowID = await enterDurableModelCall();
|
|
83
73
|
try {
|
|
84
|
-
return await DBOS.runStep(
|
|
74
|
+
return await DBOS.runStep(
|
|
75
|
+
async () => {
|
|
76
|
+
const result = ensureResponseMetadata(encodeBinaryContent(await doGenerate()));
|
|
77
|
+
// A non-streaming call writes its whole output at once, so the stream holds every call the loop makes, not only streamed ones.
|
|
78
|
+
if (streamConfig) {
|
|
79
|
+
const streamWriter = new ModelStreamWriter(streamConfig);
|
|
80
|
+
for (const part of replayParts(result)) streamWriter.push(part);
|
|
81
|
+
await streamWriter.end({ finishReason: result.finishReason });
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
},
|
|
85
|
+
{
|
|
85
86
|
...stepConfig,
|
|
86
87
|
name: stepConfig.name ?? stepName(model, 'generate'),
|
|
88
|
+
// An aborted call is never retried, whatever the provider's rejection looks like.
|
|
89
|
+
shouldRetry: async (error: unknown) => params.abortSignal?.aborted !== true && (await stepConfig.shouldRetry!(error)),
|
|
87
90
|
});
|
|
88
91
|
} catch (error) {
|
|
89
92
|
// Restore the AI SDK error identity a replay revival strips, so the SDK's retry/catch logic behaves the same.
|
|
@@ -98,41 +101,36 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
|
|
|
98
101
|
if (!isInWorkflowFunction()) {
|
|
99
102
|
return await doStream();
|
|
100
103
|
}
|
|
101
|
-
const workflowID = enterDurableModelCall(
|
|
102
|
-
// The
|
|
103
|
-
const abortSignal =
|
|
104
|
-
// An aborted consumer is done with this call, like a cancelled one: post-abort failures must checkpoint as a (partial) success, or replay would fail where the live run ended gracefully.
|
|
104
|
+
const workflowID = await enterDurableModelCall();
|
|
105
|
+
// The caller's abortSignal merged with the AI SDK's timeouts; an abort stops the provider call and is recorded as the step's outcome.
|
|
106
|
+
const abortSignal = params.abortSignal;
|
|
105
107
|
const aborted = () => abortSignal?.aborted === true;
|
|
106
108
|
|
|
107
|
-
// Free the concurrency guard as soon as the consumer detaches (abort/cancel), not only when the step
|
|
108
|
-
// settles: this call's step is already sequenced, so a sequential follow-up in the same workflow is
|
|
109
|
-
// deterministic on replay and must not be rejected as concurrent while this step drains in the background.
|
|
110
|
-
let guardReleased = false;
|
|
111
|
-
const releaseGuard = () => {
|
|
112
|
-
if (guardReleased) return;
|
|
113
|
-
guardReleased = true;
|
|
114
|
-
abortSignal?.removeEventListener('abort', releaseGuard);
|
|
115
|
-
exitDurableModelCall(workflowID);
|
|
116
|
-
};
|
|
117
|
-
abortSignal?.addEventListener('abort', releaseGuard, { once: true });
|
|
118
|
-
|
|
119
109
|
let executed = false;
|
|
120
110
|
let cancelled = false;
|
|
121
111
|
let emittedLive = false;
|
|
112
|
+
let released = false;
|
|
122
113
|
let controller!: ReadableStreamDefaultController<LanguageModelV4StreamPart>;
|
|
123
114
|
let step!: Promise<LanguageModelV4GenerateResult>;
|
|
115
|
+
let settled!: Promise<void>;
|
|
116
|
+
// A detached consumer (abort/cancel) may issue a sequential follow-up while this step still settles: make that call wait for this checkpoint instead of refusing it as concurrent.
|
|
117
|
+
const detach = () => {
|
|
118
|
+
abortSignal?.removeEventListener('abort', detach);
|
|
119
|
+
if (!released) settlingModelCalls.set(workflowID, settled);
|
|
120
|
+
};
|
|
121
|
+
abortSignal?.addEventListener('abort', detach, { once: true });
|
|
124
122
|
const stream = new ReadableStream<LanguageModelV4StreamPart>({
|
|
125
123
|
start(c) {
|
|
126
124
|
controller = c;
|
|
127
125
|
},
|
|
128
|
-
// Await the step so
|
|
126
|
+
// Await the step so a direct cancel blocks until the checkpoint; through the AI SDK's pipeline a consumer's early exit returns sooner, hence the rule to drain or abort instead.
|
|
129
127
|
async cancel() {
|
|
130
128
|
cancelled = true;
|
|
131
|
-
|
|
129
|
+
detach();
|
|
132
130
|
// Post-cancel failures normally checkpoint as a success; anything else (e.g. a failed checkpoint write) is only visible here.
|
|
133
|
-
await step.catch((error: unknown) =>
|
|
134
|
-
DBOS.logger.warn(`Durable model call step failed after consumer cancel: ${String(error)}`)
|
|
135
|
-
);
|
|
131
|
+
await step.catch((error: unknown) => {
|
|
132
|
+
if (!aborted()) DBOS.logger.warn(`Durable model call step failed after consumer cancel: ${String(error)}`);
|
|
133
|
+
});
|
|
136
134
|
},
|
|
137
135
|
});
|
|
138
136
|
const emit = (part: LanguageModelV4StreamPart) => {
|
|
@@ -143,11 +141,11 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
|
|
|
143
141
|
}
|
|
144
142
|
};
|
|
145
143
|
|
|
146
|
-
// Once any output part has streamed live, a retry would re-stream from scratch and duplicate output
|
|
144
|
+
// Once any output part has streamed live, a retry would re-stream from scratch and duplicate output; an aborted call is never retried.
|
|
147
145
|
const streamStepConfig: StepConfig = {
|
|
148
146
|
...stepConfig,
|
|
149
147
|
shouldRetry: async (error: unknown) =>
|
|
150
|
-
!emittedLive && (stepConfig.shouldRetry ? await stepConfig.shouldRetry(error) : true),
|
|
148
|
+
!emittedLive && !aborted() && (stepConfig.shouldRetry ? await stepConfig.shouldRetry(error) : true),
|
|
151
149
|
};
|
|
152
150
|
|
|
153
151
|
try {
|
|
@@ -162,22 +160,26 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
|
|
|
162
160
|
let sawFinish = false;
|
|
163
161
|
const abandon = () => void reader?.cancel().catch(() => {});
|
|
164
162
|
timeoutSignal?.addEventListener('abort', abandon, { once: true });
|
|
165
|
-
// A consumer abort
|
|
163
|
+
// A consumer abort tears the provider call down too; the attempt is then recorded as aborted below.
|
|
164
|
+
abortSignal?.addEventListener('abort', abandon, { once: true });
|
|
165
|
+
// Step-scope stream writes: cheap, replay-safe, and never duplicated since a retry is refused once content has streamed.
|
|
166
|
+
const streamWriter = streamConfig ? new ModelStreamWriter(streamConfig) : undefined;
|
|
166
167
|
try {
|
|
167
168
|
streamResult = await doStream();
|
|
168
169
|
reader = streamResult.stream.getReader();
|
|
169
170
|
for (;;) {
|
|
170
171
|
const { done, value: part } = await reader.read();
|
|
171
172
|
if (timeoutSignal?.aborted) throw (timeoutSignal.reason ?? new Error('step attempt timed out'));
|
|
172
|
-
if (done) break;
|
|
173
|
+
if (done || aborted()) break;
|
|
173
174
|
if (part.type === 'error') {
|
|
174
|
-
// A cancelled
|
|
175
|
-
if (cancelled
|
|
175
|
+
// 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
|
+
if (cancelled) break;
|
|
176
177
|
throw toStepError(part.error);
|
|
177
178
|
}
|
|
178
179
|
// Stream deltas live but withhold 'finish' until the checkpoint is durable: the AI SDK runs tool calls (and their durable steps) on 'finish', which must not checkpoint before this model step.
|
|
179
180
|
if (part.type === 'finish') sawFinish = true;
|
|
180
181
|
else emit(part);
|
|
182
|
+
streamWriter?.push(part);
|
|
181
183
|
accumulator.add(part);
|
|
182
184
|
}
|
|
183
185
|
// No terminal part and no output: fail (retryably) like the AI SDK's NoOutputGeneratedError, instead of checkpointing a permanent empty success.
|
|
@@ -185,33 +187,55 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
|
|
|
185
187
|
throw new Error('Model stream ended without a finish part or any output.');
|
|
186
188
|
}
|
|
187
189
|
} catch (error) {
|
|
188
|
-
// Same rule for stream-level failures (doStream or a read rejecting) after a cancel
|
|
189
|
-
if (!cancelled && !aborted())
|
|
190
|
+
// Same rule for stream-level failures (doStream or a read rejecting) after a cancel; after an abort, the abort is the outcome.
|
|
191
|
+
if (!cancelled && !aborted()) {
|
|
192
|
+
await streamWriter?.abandon();
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
190
195
|
} finally {
|
|
191
196
|
timeoutSignal?.removeEventListener('abort', abandon);
|
|
197
|
+
abortSignal?.removeEventListener('abort', abandon);
|
|
192
198
|
// Tear down the provider stream on early exits (error part, post-cancel break); a no-op after a clean drain.
|
|
193
199
|
void reader?.cancel().catch(() => {});
|
|
194
200
|
}
|
|
201
|
+
// Record the abort as the step's failure; replay rethrows it, so the workflow must catch aborts it means to survive.
|
|
202
|
+
if (aborted()) {
|
|
203
|
+
await streamWriter?.end({ aborted: true });
|
|
204
|
+
throw toAbortError(abortSignal!.reason);
|
|
205
|
+
}
|
|
195
206
|
// Give the response a durable id/timestamp when the provider sent none, and emit it live (before the
|
|
196
207
|
// withheld 'finish') so the SDK sees the same values live and on replay instead of a fresh fallback.
|
|
197
208
|
// Skip the live emit for a timed-out (abandoned) attempt so it can't interleave with its retry.
|
|
198
209
|
const responseMetadataPart = accumulator.fillResponseMetadata(randomUUID(), new Date());
|
|
199
210
|
if (responseMetadataPart && !timeoutSignal?.aborted) emit(responseMetadataPart);
|
|
200
|
-
|
|
211
|
+
const recorded = encodeBinaryContent(accumulator.result(streamResult?.request, streamResult?.response));
|
|
212
|
+
// Every stream write lands before the checkpoint, so a reader that sees the next step has seen all of this one.
|
|
213
|
+
await streamWriter?.end({ finishReason: recorded.finishReason });
|
|
214
|
+
return recorded;
|
|
201
215
|
},
|
|
202
216
|
{ ...streamStepConfig, name: stepConfig.name ?? stepName(model, 'stream') },
|
|
203
217
|
);
|
|
204
218
|
} catch (error) {
|
|
205
219
|
// runStep can throw synchronously (e.g. a shutdown race); don't leak the guard entry.
|
|
206
|
-
|
|
220
|
+
abortSignal?.removeEventListener('abort', detach);
|
|
221
|
+
exitDurableModelCall(workflowID);
|
|
207
222
|
throw error;
|
|
208
223
|
}
|
|
224
|
+
// Hold the guard until the step has settled, so the next call in this workflow always starts after this checkpoint.
|
|
225
|
+
settled = step.then(
|
|
226
|
+
() => undefined,
|
|
227
|
+
() => undefined,
|
|
228
|
+
).then(() => {
|
|
229
|
+
released = true;
|
|
230
|
+
abortSignal?.removeEventListener('abort', detach);
|
|
231
|
+
exitDurableModelCall(workflowID);
|
|
232
|
+
if (settlingModelCalls.get(workflowID) === settled) settlingModelCalls.delete(workflowID);
|
|
233
|
+
});
|
|
209
234
|
|
|
210
235
|
// Drive the returned stream from the settled step: a live run emits only the withheld 'finish' (deltas already
|
|
211
236
|
// streamed); a recovered run synthesizes the whole stream from the checkpoint. Either way consumers finish
|
|
212
237
|
// only after the result is durable.
|
|
213
|
-
void step
|
|
214
|
-
.then(
|
|
238
|
+
void step.then(
|
|
215
239
|
(recorded) => {
|
|
216
240
|
if (cancelled) return;
|
|
217
241
|
if (executed) {
|
|
@@ -227,10 +251,11 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
|
|
|
227
251
|
controller.close();
|
|
228
252
|
},
|
|
229
253
|
(error: unknown) => {
|
|
230
|
-
if (
|
|
254
|
+
if (cancelled) return;
|
|
255
|
+
// Live abort: surface the signal's own reason so the AI SDK takes its abort path; a replayed abort is an ordinary error.
|
|
256
|
+
controller.error(executed && aborted() ? (abortSignal?.reason ?? error) : restoreAISDKErrorIdentity(error));
|
|
231
257
|
},
|
|
232
|
-
)
|
|
233
|
-
.finally(releaseGuard);
|
|
258
|
+
);
|
|
234
259
|
|
|
235
260
|
return { stream };
|
|
236
261
|
},
|
|
@@ -242,12 +267,17 @@ export function durableEmbeddingCalls(options: StepConfig = {}): EmbeddingModelM
|
|
|
242
267
|
const stepConfig = withErrorClassification(options);
|
|
243
268
|
return {
|
|
244
269
|
specificationVersion: 'v4',
|
|
270
|
+
// embedMany awaits this per call: inside a workflow the batches run sequentially (deterministic step order on replay); elsewhere the model's own answer stands.
|
|
271
|
+
overrideSupportsParallelCalls: ({ model }) => ({
|
|
272
|
+
then: (onfulfilled, onrejected) =>
|
|
273
|
+
Promise.resolve(isInWorkflowFunction() ? false : model.supportsParallelCalls).then(onfulfilled, onrejected),
|
|
274
|
+
}),
|
|
245
275
|
wrapEmbed: async ({ doEmbed, model }) => {
|
|
246
276
|
assertNotInTransaction('embed');
|
|
247
277
|
if (!isInWorkflowFunction()) {
|
|
248
278
|
return await doEmbed();
|
|
249
279
|
}
|
|
250
|
-
const workflowID = enterDurableModelCall(
|
|
280
|
+
const workflowID = await enterDurableModelCall();
|
|
251
281
|
try {
|
|
252
282
|
return await DBOS.runStep(async () => doEmbed(), {
|
|
253
283
|
...stepConfig,
|
|
@@ -288,6 +318,12 @@ export function durableImageCalls(options: StepConfig = {}): ImageModelMiddlewar
|
|
|
288
318
|
};
|
|
289
319
|
}
|
|
290
320
|
|
|
321
|
+
// The abort reason as a recordable Error (an AbortSignal's reason may be any value); the name is what the AI SDK's abort checks read.
|
|
322
|
+
function toAbortError(reason: unknown): Error {
|
|
323
|
+
if (reason instanceof Error) return reason;
|
|
324
|
+
return Object.assign(new Error(String(reason)), { name: 'AbortError' });
|
|
325
|
+
}
|
|
326
|
+
|
|
291
327
|
/** Convert generated image bytes (Uint8Array) to base64 (spec-allowed) to keep checkpoints compact. */
|
|
292
328
|
function encodeImageResult(result: ImageModelV4Result): ImageModelV4Result {
|
|
293
329
|
const images = result.images as (string | Uint8Array)[];
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { DBOS, StepConfig } from '@dbos-inc/dbos-sdk';
|
|
2
|
+
import type { ToolSet } from 'ai' with { 'resolution-mode': 'import' };
|
|
3
|
+
import { assertNotInTransaction, isAsyncIterable, isInWorkflowFunction, runDurableStep, withErrorClassification } from './internal';
|
|
4
|
+
import { AGENT_TOOL } from './agent-tool';
|
|
5
|
+
import { writeToolRecord } from './durable-stream';
|
|
6
|
+
|
|
7
|
+
export interface DurableToolsOptions extends StepConfig {
|
|
8
|
+
/** Per-tool step config overriding the defaults; `false` leaves that tool non-durable. */
|
|
9
|
+
tools?: Record<string, StepConfig | false>;
|
|
10
|
+
/** Write each tool call's output (or error) to this durable stream from inside its step. */
|
|
11
|
+
durableStream?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Loose view of a tool's execute; the AI SDK validates input and supplies the options.
|
|
15
|
+
type ToolExecute = (input: unknown, options: { toolCallId: string; abortSignal?: AbortSignal }) => unknown;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps each tool's `execute` so that, inside a workflow, every tool call runs as a durable DBOS step named
|
|
19
|
+
* `<tool>.<toolCallId>` and replays from its checkpoint on recovery; outside a workflow tools run unchanged.
|
|
20
|
+
* Retries are off by default (the AI SDK never retries tools); opt in per tool with `retriesAllowed`.
|
|
21
|
+
*/
|
|
22
|
+
export function durableTools<TOOLS extends ToolSet>(tools: TOOLS, options: DurableToolsOptions = {}): TOOLS {
|
|
23
|
+
const { tools: perTool, durableStream, ...defaults } = options;
|
|
24
|
+
const durable: ToolSet = {};
|
|
25
|
+
for (const [name, definition] of Object.entries(tools)) {
|
|
26
|
+
const override = perTool?.[name];
|
|
27
|
+
// An agent tool is a child workflow, not a step: leave it unwrapped, binding this durable stream to it.
|
|
28
|
+
const bindAgentTool = (definition as { [AGENT_TOOL]?: (key: string) => ToolSet[string] })[AGENT_TOOL];
|
|
29
|
+
if (bindAgentTool) {
|
|
30
|
+
durable[name] = durableStream ? bindAgentTool(durableStream) : definition;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (typeof definition.execute !== 'function' || override === false) {
|
|
34
|
+
durable[name] = definition;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const execute = definition.execute as ToolExecute;
|
|
38
|
+
const merged: StepConfig = { ...defaults, ...override };
|
|
39
|
+
// Default classification (aborts and provider-declared non-retryable errors are terminal), but retries stay opt-in.
|
|
40
|
+
const stepConfig: StepConfig = { ...withErrorClassification(merged), retriesAllowed: merged.retriesAllowed ?? false };
|
|
41
|
+
const prefix = stepConfig.name ?? name;
|
|
42
|
+
durable[name] = {
|
|
43
|
+
...definition,
|
|
44
|
+
execute: (input: unknown, execOptions: Parameters<ToolExecute>[1]) => {
|
|
45
|
+
assertNotInTransaction(name);
|
|
46
|
+
if (!isInWorkflowFunction()) return execute(input, execOptions);
|
|
47
|
+
const signal = execOptions.abortSignal;
|
|
48
|
+
// An aborted call is done whatever the failure looks like; a retry would re-run a cancelled side effect.
|
|
49
|
+
const callConfig: StepConfig = {
|
|
50
|
+
...stepConfig,
|
|
51
|
+
shouldRetry: async (error: unknown) => !signal?.aborted && (await stepConfig.shouldRetry!(error)),
|
|
52
|
+
};
|
|
53
|
+
// The tool call id comes from the checkpointed model result, so a reordered parallel step fails replay instead of swapping results.
|
|
54
|
+
return runDurableStep(
|
|
55
|
+
`${prefix}.${execOptions.toolCallId}`,
|
|
56
|
+
async () => {
|
|
57
|
+
// A timed-out attempt is abandoned by DBOS but keeps running; forward its signal so the tool stops too.
|
|
58
|
+
const timeoutSignal = DBOS.stepStatus?.timeoutSignal;
|
|
59
|
+
const abortSignal = timeoutSignal && signal ? AbortSignal.any([signal, timeoutSignal]) : (timeoutSignal ?? signal);
|
|
60
|
+
let output: unknown;
|
|
61
|
+
try {
|
|
62
|
+
output = await execute(input, abortSignal === signal ? execOptions : { ...execOptions, abortSignal });
|
|
63
|
+
// A streaming execute can't checkpoint mid-flight; drain it and record the final value (the last yield).
|
|
64
|
+
if (isAsyncIterable(output)) {
|
|
65
|
+
let last: unknown;
|
|
66
|
+
for await (last of output);
|
|
67
|
+
output = last;
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (durableStream) await writeToolRecord(durableStream, execOptions.toolCallId, { errorText: errorMessage(error) });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
if (durableStream) await writeToolRecord(durableStream, execOptions.toolCallId, { output });
|
|
74
|
+
return output;
|
|
75
|
+
},
|
|
76
|
+
callConfig,
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
} as ToolSet[string];
|
|
80
|
+
}
|
|
81
|
+
return durable as TOOLS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorMessage(error: unknown): string {
|
|
85
|
+
return error instanceof Error ? error.message : String(error);
|
|
86
|
+
}
|