@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/src/tools.ts CHANGED
@@ -1,14 +1,108 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  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';
3
+ import type { ToolSet, UIMessageChunk, UIMessageStreamWriter } from 'ai' with { 'resolution-mode': 'import' };
4
+ import { anySignal, assertNotInTransaction, isAsyncIterable, isInWorkflowFunction, runDurableStep, stepCancelSignal, timedOutOutcome, withErrorClassification } from './internal';
4
5
  import { AGENT_TOOL } from './agent-tool';
5
- import { writeToolRecord } from './durable-stream';
6
+ import { writeDurableStream, writeToolRecord } from './durable-stream';
6
7
 
7
8
  export interface DurableToolsOptions extends StepConfig {
8
9
  /** Per-tool step config overriding the defaults; `false` leaves that tool non-durable. */
9
10
  tools?: Record<string, StepConfig | false>;
10
11
  /** Write each tool call's output (or error) to this durable stream from inside its step. */
11
12
  durableStream?: string;
13
+ /** Receives chunks tools write via `toolWriter()`; those that are part of the message are checkpointed and re-emitted on replay, before the tool's output. */
14
+ writer?: UIMessageStreamWriter;
15
+ }
16
+
17
+ const currentWriter = new AsyncLocalStorage<UIMessageStreamWriter>();
18
+
19
+ /** Returns a UI message stream writer bound to the current `durableTools` tool call. */
20
+ export function toolWriter(): UIMessageStreamWriter {
21
+ const writer = currentWriter.getStore();
22
+ if (!writer) throw new Error('toolWriter() can only be called from a tool wrapped by durableTools.');
23
+ return writer;
24
+ }
25
+
26
+ // Tool output plus the message chunks its call wrote, as checkpointed; a bare output is a call that wrote none.
27
+ interface ToolEnvelope {
28
+ __dbosToolChunks: 1;
29
+ output: unknown;
30
+ chunks: UIMessageChunk[];
31
+ }
32
+
33
+ function isToolEnvelope(value: unknown): value is ToolEnvelope {
34
+ return typeof value === 'object' && value !== null && (value as Partial<ToolEnvelope>).__dbosToolChunks === 1;
35
+ }
36
+
37
+ function isTransient(chunk: UIMessageChunk): boolean {
38
+ return (chunk as { transient?: boolean }).transient === true;
39
+ }
40
+
41
+ // An async generator's body runs as it is iterated, outside run(); bind each step of the iteration to the writer.
42
+ function runWithWriter(writer: UIMessageStreamWriter, fn: () => unknown): unknown {
43
+ const result = currentWriter.run(writer, fn);
44
+ if (!isAsyncIterable(result)) return result;
45
+ const iterator = currentWriter.run(writer, () => result[Symbol.asyncIterator]());
46
+ return {
47
+ [Symbol.asyncIterator]() {
48
+ return this;
49
+ },
50
+ next: (...args: [] | [unknown]) => currentWriter.run(writer, () => iterator.next(...args)),
51
+ return: (value?: unknown) => currentWriter.run(writer, () => iterator.return?.(value) ?? Promise.resolve({ done: true as const, value })),
52
+ throw: (error?: unknown) => currentWriter.run(writer, () => iterator.throw?.(error) ?? Promise.reject(error)),
53
+ };
54
+ }
55
+
56
+ // Outside a step: every chunk goes straight to the writer, or nowhere.
57
+ function liveWriter(writer: UIMessageStreamWriter | undefined): UIMessageStreamWriter {
58
+ return writer ?? { write() {}, merge() {}, onError: undefined };
59
+ }
60
+
61
+ /** One attempt's writer: transient chunks go out live, the rest wait for the step to succeed. */
62
+ class StepWriter implements UIMessageStreamWriter {
63
+ readonly chunks: UIMessageChunk[] = [];
64
+ private pending: Promise<unknown>[] = [];
65
+
66
+ constructor(
67
+ private readonly writer: UIMessageStreamWriter | undefined,
68
+ private readonly durableStream: string | undefined,
69
+ ) {}
70
+
71
+ get onError() {
72
+ return this.writer?.onError;
73
+ }
74
+
75
+ write(chunk: UIMessageChunk): void {
76
+ if (!isTransient(chunk)) {
77
+ this.chunks.push(chunk);
78
+ return;
79
+ }
80
+ this.writer?.write(chunk);
81
+ if (this.durableStream) this.track(writeDurableStream(this.durableStream, [chunk]));
82
+ }
83
+
84
+ merge(stream: ReadableStream<UIMessageChunk>): void {
85
+ this.track(
86
+ (async () => {
87
+ for await (const chunk of stream) this.write(chunk);
88
+ })(),
89
+ );
90
+ }
91
+
92
+ // Observed now so a failure after the tool throws is not an unhandled rejection; settle still sees it.
93
+ private track(promise: Promise<unknown>): void {
94
+ promise.catch(() => {});
95
+ this.pending.push(promise);
96
+ }
97
+
98
+ /** Waits for merges and live writes, including any started while waiting; a failed one fails the call. */
99
+ async settle(): Promise<void> {
100
+ while (this.pending.length > 0) {
101
+ const batch = this.pending;
102
+ this.pending = [];
103
+ await Promise.all(batch);
104
+ }
105
+ }
12
106
  }
13
107
 
14
108
  // Loose view of a tool's execute; the AI SDK validates input and supplies the options.
@@ -20,7 +114,8 @@ type ToolExecute = (input: unknown, options: { toolCallId: string; abortSignal?:
20
114
  * Retries are off by default (the AI SDK never retries tools); opt in per tool with `retriesAllowed`.
21
115
  */
22
116
  export function durableTools<TOOLS extends ToolSet>(tools: TOOLS, options: DurableToolsOptions = {}): TOOLS {
23
- const { tools: perTool, durableStream, ...defaults } = options;
117
+ const { tools: perTool, durableStream, writer, ...defaults } = options;
118
+ const live = liveWriter(writer);
24
119
  const durable: ToolSet = {};
25
120
  for (const [name, definition] of Object.entries(tools)) {
26
121
  const override = perTool?.[name];
@@ -30,11 +125,18 @@ export function durableTools<TOOLS extends ToolSet>(tools: TOOLS, options: Durab
30
125
  durable[name] = durableStream ? bindAgentTool(durableStream) : definition;
31
126
  continue;
32
127
  }
33
- if (typeof definition.execute !== 'function' || override === false) {
128
+ if (typeof definition.execute !== 'function') {
34
129
  durable[name] = definition;
35
130
  continue;
36
131
  }
37
132
  const execute = definition.execute as ToolExecute;
133
+ if (override === false) {
134
+ durable[name] = {
135
+ ...definition,
136
+ execute: (input: unknown, execOptions: Parameters<ToolExecute>[1]) => runWithWriter(live, () => execute(input, execOptions)),
137
+ } as ToolSet[string];
138
+ continue;
139
+ }
38
140
  const merged: StepConfig = { ...defaults, ...override };
39
141
  // Default classification (aborts and provider-declared non-retryable errors are terminal), but retries stay opt-in.
40
142
  const stepConfig: StepConfig = { ...withErrorClassification(merged), retriesAllowed: merged.retriesAllowed ?? false };
@@ -43,7 +145,7 @@ export function durableTools<TOOLS extends ToolSet>(tools: TOOLS, options: Durab
43
145
  ...definition,
44
146
  execute: (input: unknown, execOptions: Parameters<ToolExecute>[1]) => {
45
147
  assertNotInTransaction(name);
46
- if (!isInWorkflowFunction()) return execute(input, execOptions);
148
+ if (!isInWorkflowFunction()) return runWithWriter(live, () => execute(input, execOptions));
47
149
  const signal = execOptions.abortSignal;
48
150
  // An aborted call is done whatever the failure looks like; a retry would re-run a cancelled side effect.
49
151
  const callConfig: StepConfig = {
@@ -54,27 +156,43 @@ export function durableTools<TOOLS extends ToolSet>(tools: TOOLS, options: Durab
54
156
  return runDurableStep(
55
157
  `${prefix}.${execOptions.toolCallId}`,
56
158
  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);
159
+ // A timed-out attempt is abandoned by DBOS but keeps running, and a cancelled workflow's tool should stop: forward both signals.
160
+ const cancelSignal = stepCancelSignal();
161
+ const abortSignal = anySignal(signal, DBOS.stepStatus?.timeoutSignal, cancelSignal);
162
+ const stepWriter = new StepWriter(writer, durableStream);
163
+ const record = async (outcome: { output: unknown } | { errorText: string }, chunks?: UIMessageChunk[]) => {
164
+ if (!durableStream) return;
165
+ const timeout = await timedOutOutcome(callConfig.shouldRetry);
166
+ if (timeout === null) return;
167
+ await writeToolRecord(durableStream, execOptions.toolCallId, timeout ?? outcome, timeout ? [] : chunks);
168
+ };
60
169
  let output: unknown;
61
170
  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)) {
171
+ output = await currentWriter.run(stepWriter, async () => {
172
+ const value = await execute(input, abortSignal === signal ? execOptions : { ...execOptions, abortSignal });
173
+ // A streaming execute can't checkpoint mid-flight; drain it and record the final value (the last yield).
174
+ if (!isAsyncIterable(value)) return value;
65
175
  let last: unknown;
66
- for await (last of output);
67
- output = last;
68
- }
176
+ for await (last of value);
177
+ return last;
178
+ });
179
+ await stepWriter.settle();
69
180
  } catch (error) {
70
- if (durableStream) await writeToolRecord(durableStream, execOptions.toolCallId, { errorText: errorMessage(error) });
181
+ // A cancelled workflow's reader ends the turn with an abort; this call has no outcome of its own.
182
+ if (!cancelSignal?.aborted) await record({ errorText: errorMessage(error) });
71
183
  throw error;
72
184
  }
73
- if (durableStream) await writeToolRecord(durableStream, execOptions.toolCallId, { output });
74
- return output;
185
+ const chunks = stepWriter.chunks;
186
+ await record({ output }, chunks);
187
+ return chunks.length > 0 ? ({ __dbosToolChunks: 1, output, chunks } satisfies ToolEnvelope) : output;
75
188
  },
76
189
  callConfig,
77
- );
190
+ ).then((result) => {
191
+ if (!isToolEnvelope(result)) return result;
192
+ // Runs on first execution and on replay alike, so the workflow's message gets the same parts either way.
193
+ for (const chunk of result.chunks) writer?.write(chunk);
194
+ return result.output;
195
+ });
78
196
  },
79
197
  } as ToolSet[string];
80
198
  }