@dbos-inc/vercel-ai 0.2.5 → 0.4.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/src/middleware.ts CHANGED
@@ -21,24 +21,28 @@ 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
+ /** 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 };
31
+ }
24
32
 
25
33
  // In-flight durable model calls per workflow; concurrent calls have a nondeterministic DBOS step order on replay, so we reject them.
26
34
  const inflightModelCalls = new Map<string, number>();
35
+ // A stream call whose consumer detached (abort/cancel) but whose step is still settling; the next call in that workflow waits for it.
36
+ const settlingModelCalls = new Map<string, Promise<void>>();
27
37
 
28
- // Consumer abort signals withheld from the provider inside a workflow, keyed by the transformed params wrapStream receives.
29
- const detachedAbortSignals = new WeakMap<object, AbortSignal>();
30
-
31
- function enterDurableModelCall(operation: 'generate' | 'stream' | 'embed'): string {
38
+ async function enterDurableModelCall(): Promise<string> {
32
39
  const workflowID = DBOS.workflowID!;
40
+ // Sequence after a detached call's checkpoint, so steps are always recorded in the order they were started.
41
+ await settlingModelCalls.get(workflowID);
33
42
  const inflight = inflightModelCalls.get(workflowID) ?? 0;
34
43
  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
44
  throw new Error(
41
- `Concurrent durable model calls in workflow "${workflowID}" are not supported because their step order is nondeterministic on replay; ${remedy}.`,
45
+ `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
46
  );
43
47
  }
44
48
  inflightModelCalls.set(workflowID, inflight + 1);
@@ -55,35 +59,36 @@ function exitDurableModelCall(workflowID: string): void {
55
59
  }
56
60
 
57
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. */
58
- export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware {
59
- const stepConfig = withErrorClassification(options);
62
+ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMiddleware {
63
+ const { durableStream, include, ...stepOptions } = options;
64
+ const stepConfig = withErrorClassification(stepOptions);
65
+ const streamConfig = resolveDurableStream(durableStream);
60
66
  return {
61
67
  specificationVersion: 'v4',
62
68
 
63
- // A consumer abort (a stop button, an AI SDK timeout, a shutting-down worker) must not cut a durable stream short:
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 }) => {
69
+ wrapGenerate: async ({ doGenerate, params, model }) => {
78
70
  assertNotInTransaction('generate');
79
71
  if (!isInWorkflowFunction()) {
80
72
  return await doGenerate();
81
73
  }
82
- const workflowID = enterDurableModelCall('generate');
74
+ const workflowID = await enterDurableModelCall();
83
75
  try {
84
- return await DBOS.runStep(async () => ensureResponseMetadata(encodeBinaryContent(await doGenerate())), {
76
+ return await DBOS.runStep(
77
+ async () => {
78
+ const result = ensureResponseMetadata(encodeBinaryContent(omitBodies(await doGenerate(), include)));
79
+ // A non-streaming call writes its whole output at once, so the stream holds every call the loop makes, not only streamed ones.
80
+ if (streamConfig) {
81
+ const streamWriter = new ModelStreamWriter(streamConfig);
82
+ for (const part of replayParts(result)) streamWriter.push(part);
83
+ await streamWriter.end({ finishReason: result.finishReason });
84
+ }
85
+ return result;
86
+ },
87
+ {
85
88
  ...stepConfig,
86
89
  name: stepConfig.name ?? stepName(model, 'generate'),
90
+ // An aborted call is never retried, whatever the provider's rejection looks like.
91
+ shouldRetry: async (error: unknown) => params.abortSignal?.aborted !== true && (await stepConfig.shouldRetry!(error)),
87
92
  });
88
93
  } catch (error) {
89
94
  // Restore the AI SDK error identity a replay revival strips, so the SDK's retry/catch logic behaves the same.
@@ -98,41 +103,36 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
98
103
  if (!isInWorkflowFunction()) {
99
104
  return await doStream();
100
105
  }
101
- const workflowID = enterDurableModelCall('stream');
102
- // The consumer's signal, detached from the provider call by transformParams (undefined outside a workflow).
103
- const abortSignal = detachedAbortSignals.get(params);
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.
106
+ const workflowID = await enterDurableModelCall();
107
+ // 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.
108
+ const abortSignal = params.abortSignal;
105
109
  const aborted = () => abortSignal?.aborted === true;
106
110
 
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
111
  let executed = false;
120
112
  let cancelled = false;
121
113
  let emittedLive = false;
114
+ let released = false;
122
115
  let controller!: ReadableStreamDefaultController<LanguageModelV4StreamPart>;
123
116
  let step!: Promise<LanguageModelV4GenerateResult>;
117
+ let settled!: Promise<void>;
118
+ // 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.
119
+ const detach = () => {
120
+ abortSignal?.removeEventListener('abort', detach);
121
+ if (!released) settlingModelCalls.set(workflowID, settled);
122
+ };
123
+ abortSignal?.addEventListener('abort', detach, { once: true });
124
124
  const stream = new ReadableStream<LanguageModelV4StreamPart>({
125
125
  start(c) {
126
126
  controller = c;
127
127
  },
128
- // Await the step so an early cancel still blocks until the model result is checkpointed.
128
+ // 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
129
  async cancel() {
130
130
  cancelled = true;
131
- releaseGuard();
131
+ detach();
132
132
  // 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
- );
133
+ await step.catch((error: unknown) => {
134
+ if (!aborted()) DBOS.logger.warn(`Durable model call step failed after consumer cancel: ${String(error)}`);
135
+ });
136
136
  },
137
137
  });
138
138
  const emit = (part: LanguageModelV4StreamPart) => {
@@ -143,11 +143,11 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
143
143
  }
144
144
  };
145
145
 
146
- // Once any output part has streamed live, a retry would re-stream from scratch and duplicate output, so stop retrying.
146
+ // Once any output part has streamed live, a retry would re-stream from scratch and duplicate output; an aborted call is never retried.
147
147
  const streamStepConfig: StepConfig = {
148
148
  ...stepConfig,
149
149
  shouldRetry: async (error: unknown) =>
150
- !emittedLive && (stepConfig.shouldRetry ? await stepConfig.shouldRetry(error) : true),
150
+ !emittedLive && !aborted() && (stepConfig.shouldRetry ? await stepConfig.shouldRetry(error) : true),
151
151
  };
152
152
 
153
153
  try {
@@ -157,27 +157,29 @@ export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware
157
157
  const accumulator = new StreamAccumulator();
158
158
  // 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.
159
159
  const timeoutSignal = DBOS.stepStatus?.timeoutSignal;
160
- let streamResult: Awaited<ReturnType<typeof doStream>> | undefined;
161
160
  let reader: ReadableStreamDefaultReader<LanguageModelV4StreamPart> | undefined;
162
161
  let sawFinish = false;
163
162
  const abandon = () => void reader?.cancel().catch(() => {});
164
163
  timeoutSignal?.addEventListener('abort', abandon, { once: true });
165
- // A consumer abort deliberately does not abandon the drain: the step keeps reading so the checkpoint is complete.
164
+ // A consumer abort tears the provider call down too; the attempt is then recorded as aborted below.
165
+ abortSignal?.addEventListener('abort', abandon, { once: true });
166
+ // Step-scope stream writes: cheap, replay-safe, and never duplicated since a retry is refused once content has streamed.
167
+ const streamWriter = streamConfig ? new ModelStreamWriter(streamConfig) : undefined;
166
168
  try {
167
- streamResult = await doStream();
168
- reader = streamResult.stream.getReader();
169
+ reader = (await doStream()).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 or aborted consumer abandoned this call; don't let a late failure become the step outcome, or replay would fail where the live run succeeded.
175
- if (cancelled || aborted()) break;
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 or abort.
189
- if (!cancelled && !aborted()) throw error;
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
- return encodeBinaryContent(accumulator.result(streamResult?.request, streamResult?.response));
211
+ const recorded = encodeBinaryContent(accumulator.result());
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
- releaseGuard();
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 (!cancelled) controller.error(restoreAISDKErrorIdentity(error));
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('embed');
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)[];
@@ -335,6 +371,21 @@ function ensureResponseMetadata(result: LanguageModelV4GenerateResult): Language
335
371
  };
336
372
  }
337
373
 
374
+ // generateText discards the raw bodies unless `include` asks for them, and the request body repeats the whole prompt.
375
+ function omitBodies(
376
+ result: LanguageModelV4GenerateResult,
377
+ include: DurableCallsOptions['include'],
378
+ ): LanguageModelV4GenerateResult {
379
+ const dropRequest = !include?.requestBody && result.request?.body !== undefined;
380
+ const dropResponse = !include?.responseBody && result.response?.body !== undefined;
381
+ if (!dropRequest && !dropResponse) return result;
382
+ return {
383
+ ...result,
384
+ request: dropRequest ? { ...result.request, body: undefined } : result.request,
385
+ response: dropResponse ? { ...result.response, body: undefined } : result.response,
386
+ };
387
+ }
388
+
338
389
  /** Convert generated-file bytes (Uint8Array) to base64 (spec-allowed) to keep checkpoints compact. */
339
390
  function encodeBinaryContent(result: LanguageModelV4GenerateResult): LanguageModelV4GenerateResult {
340
391
  const content = result.content.map(encodeBinaryPart);
@@ -479,18 +530,15 @@ class StreamAccumulator {
479
530
  return block;
480
531
  }
481
532
 
482
- result(
483
- request?: { body?: unknown },
484
- response?: { headers?: Record<string, string> },
485
- ): LanguageModelV4GenerateResult {
533
+ // Omits the provider's request/response (wrapStream returns only the stream): the request body repeats the whole prompt.
534
+ result(): LanguageModelV4GenerateResult {
486
535
  return {
487
536
  content: this.content,
488
537
  finishReason: this.finishReason,
489
538
  usage: this.usage,
490
539
  warnings: this.warnings,
491
540
  providerMetadata: this.providerMetadata,
492
- request,
493
- response: this.responseMetadata ? { ...this.responseMetadata, ...response } : response,
541
+ response: this.responseMetadata,
494
542
  };
495
543
  }
496
544
  }
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
+ }