@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/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'
|
|
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
|
|
58
|
-
const
|
|
59
|
-
const abortSignal =
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
}
|
|
176
|
+
for await (last of value);
|
|
177
|
+
return last;
|
|
178
|
+
});
|
|
179
|
+
await stepWriter.settle();
|
|
69
180
|
} catch (error) {
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
}
|