@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/README.md +111 -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 +351 -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 +11 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +110 -67
- 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 +395 -0
- package/src/index.ts +12 -1
- package/src/internal.ts +14 -0
- package/src/mcp.ts +28 -25
- package/src/middleware.ts +123 -75
- package/src/tools.ts +86 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { DBOS } from '@dbos-inc/dbos-sdk';
|
|
3
|
+
import type { FlexibleSchema, ModelMessage, Tool } from 'ai' with { 'resolution-mode': 'import' };
|
|
4
|
+
import { writeDurableStream, writeToolRecord } from './durable-stream';
|
|
5
|
+
import { isInWorkflowFunction } from './internal';
|
|
6
|
+
|
|
7
|
+
/** Marks a tool built by agentTool: durableTools leaves it unwrapped (it is a child workflow, not a step) and binds its durable stream. */
|
|
8
|
+
export const AGENT_TOOL: unique symbol = Symbol.for('@dbos-inc/vercel-ai/agentTool');
|
|
9
|
+
|
|
10
|
+
// Captured at module load, outside any workflow: a cancellation triggered by an abort must not claim a function id in the parent's log.
|
|
11
|
+
const outsideWorkflow = AsyncLocalStorage.snapshot();
|
|
12
|
+
|
|
13
|
+
// The child's row appears shortly after the call starts and a cancel of a missing row is a no-op, so wait for it, giving up once the call has settled.
|
|
14
|
+
async function cancelChild(childID: string, settled: () => boolean): Promise<void> {
|
|
15
|
+
for (let i = 0; i < 100 && !settled(); i++) {
|
|
16
|
+
if (await DBOS.getWorkflowStatus(childID)) {
|
|
17
|
+
// The child's own sub-agents are its children; without the cascade they would run on with nobody awaiting them.
|
|
18
|
+
await DBOS.cancelWorkflow(childID, { cancelChildren: true });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// What agentTool needs from an agent: the AI SDK's Agent interface, structurally.
|
|
26
|
+
type StreamingAgent = {
|
|
27
|
+
stream(options: { prompt: string } | { messages: ModelMessage[] }): PromiseLike<{ consumeStream(): PromiseLike<void>; readonly text: PromiseLike<string> }>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface AgentToolOptions<INPUT, AGENT extends StreamingAgent, OUTPUT> {
|
|
31
|
+
/** Name of the child workflow; must be unique. */
|
|
32
|
+
name: string;
|
|
33
|
+
description: string;
|
|
34
|
+
inputSchema: FlexibleSchema<INPUT>;
|
|
35
|
+
agent: AGENT;
|
|
36
|
+
/** Turns the tool's input into the sub-agent's prompt. */
|
|
37
|
+
prompt: (input: INPUT) => string | ModelMessage[];
|
|
38
|
+
/** Turns the sub-agent's result into the tool's output (default: its final text); must be serializable. */
|
|
39
|
+
output?: (result: Awaited<ReturnType<AGENT['stream']>>) => OUTPUT | Promise<OUTPUT>;
|
|
40
|
+
/** Record the call in this durable stream: a `data-dbos-subagent` part naming the child workflow, then its output. */
|
|
41
|
+
durableStream?: string;
|
|
42
|
+
/** Name of a DBOS queue to run each child on, e.g. to bound how many sub-agents run at once. */
|
|
43
|
+
queue?: string;
|
|
44
|
+
/** Workflow timeout for each child. */
|
|
45
|
+
timeoutMS?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AgentTool<INPUT, OUTPUT> = Tool<INPUT, OUTPUT> & {
|
|
49
|
+
/** The registered child workflow; call it directly to run the sub-agent without a model in the loop. */
|
|
50
|
+
workflow: (input: INPUT) => Promise<OUTPUT>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wraps an agent as a tool whose every call runs as a child workflow: durable at model-call granularity, with its own
|
|
55
|
+
* concurrency guard, safe to call in parallel, and visible as a child in the parent's step list. Call it at module load,
|
|
56
|
+
* before `DBOS.launch()`, since it registers the child workflow.
|
|
57
|
+
*/
|
|
58
|
+
export function agentTool<INPUT, AGENT extends StreamingAgent, OUTPUT = string>(
|
|
59
|
+
options: AgentToolOptions<INPUT, AGENT, OUTPUT>,
|
|
60
|
+
): AgentTool<INPUT, OUTPUT> {
|
|
61
|
+
const { name, agent, prompt, output } = options;
|
|
62
|
+
const run = async (input: INPUT): Promise<OUTPUT> => {
|
|
63
|
+
const request = prompt(input);
|
|
64
|
+
// stream, not generate: only streamed calls write to a durable stream.
|
|
65
|
+
const result = await agent.stream(typeof request === 'string' ? { prompt: request } : { messages: request });
|
|
66
|
+
await result.consumeStream();
|
|
67
|
+
return output ? await output(result as Awaited<ReturnType<AGENT['stream']>>) : ((await result.text) as OUTPUT);
|
|
68
|
+
};
|
|
69
|
+
const registered = DBOS.registerWorkflow(run, { name });
|
|
70
|
+
// Unbound on purpose: DBOS's registered invoker reads `this`, and as a method `this` would be the tool object.
|
|
71
|
+
const workflow = (input: INPUT): Promise<OUTPUT> => registered(input);
|
|
72
|
+
return build(options, registered, workflow, options.durableStream);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function build<INPUT, AGENT extends StreamingAgent, OUTPUT>(
|
|
76
|
+
options: AgentToolOptions<INPUT, AGENT, OUTPUT>,
|
|
77
|
+
registered: (input: INPUT) => Promise<OUTPUT>,
|
|
78
|
+
workflow: (input: INPUT) => Promise<OUTPUT>,
|
|
79
|
+
durableStream: string | undefined,
|
|
80
|
+
): AgentTool<INPUT, OUTPUT> {
|
|
81
|
+
const { name, description, inputSchema, queue, timeoutMS } = options;
|
|
82
|
+
const execute = async (input: INPUT, execOptions: { toolCallId: string; abortSignal?: AbortSignal }): Promise<OUTPUT> => {
|
|
83
|
+
if (!isInWorkflowFunction()) return workflow(input);
|
|
84
|
+
const { toolCallId } = execOptions;
|
|
85
|
+
// The tool call id comes from the checkpointed model output, so the child id is the same on replay and known for cancellation.
|
|
86
|
+
const childID = `${DBOS.workflowID}-${toolCallId}`;
|
|
87
|
+
// Start and getResult each reserve their function id synchronously here, so parallel calls replay in order.
|
|
88
|
+
const started = DBOS.startWorkflow(registered, { workflowID: childID, queueName: queue, timeoutMS })(input);
|
|
89
|
+
const pending = DBOS.getResult<OUTPUT>(childID);
|
|
90
|
+
started.catch(() => {});
|
|
91
|
+
pending.catch(() => {});
|
|
92
|
+
let settled = false;
|
|
93
|
+
const cancel = () => void outsideWorkflow(() => cancelChild(childID, () => settled)).catch(() => {});
|
|
94
|
+
execOptions.abortSignal?.addEventListener('abort', cancel, { once: true });
|
|
95
|
+
if (execOptions.abortSignal?.aborted) cancel();
|
|
96
|
+
try {
|
|
97
|
+
if (durableStream) {
|
|
98
|
+
await writeDurableStream(durableStream, [
|
|
99
|
+
{ type: 'data-dbos-subagent', id: toolCallId, data: { toolCallId, workflowID: childID, name } },
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
await started;
|
|
103
|
+
const result = (await pending) as OUTPUT;
|
|
104
|
+
// A tool record, not a raw chunk, so the reader masks a sub-agent's error text like any other tool's.
|
|
105
|
+
if (durableStream) await writeToolRecord(durableStream, toolCallId, { output: result });
|
|
106
|
+
return result;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (durableStream) await writeToolRecord(durableStream, toolCallId, { errorText: error instanceof Error ? error.message : String(error) });
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
settled = true;
|
|
112
|
+
execOptions.abortSignal?.removeEventListener('abort', cancel);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
description,
|
|
117
|
+
inputSchema,
|
|
118
|
+
execute,
|
|
119
|
+
workflow,
|
|
120
|
+
[AGENT_TOOL]: (key: string) => build(options, registered, workflow, key),
|
|
121
|
+
} as unknown as AgentTool<INPUT, OUTPUT>;
|
|
122
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { DBOS, Error as DBOSErrors, StatusString } from '@dbos-inc/dbos-sdk';
|
|
3
|
+
import type { UIMessageChunk } from 'ai' with { 'resolution-mode': 'import' };
|
|
4
|
+
import type { LanguageModelV4FinishReason, LanguageModelV4StreamPart } from '@ai-sdk/provider' with { 'resolution-mode': 'import' };
|
|
5
|
+
|
|
6
|
+
/** Durable stream config: the DBOS stream key, or the key plus batching limits for the model step's writes. */
|
|
7
|
+
export type DurableStreamOptions = string | { key: string; maxBatchParts?: number; maxBatchDelayMs?: number };
|
|
8
|
+
|
|
9
|
+
/** One DBOS stream value; the reader turns these into AI SDK UI message chunks. */
|
|
10
|
+
export type DurableStreamRecord =
|
|
11
|
+
| { kind: 'model'; step: number; attempt: string; parts: LanguageModelV4StreamPart[] }
|
|
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 }
|
|
14
|
+
| { kind: 'ui'; step?: number; attempt?: number; chunks: UIMessageChunk[] }
|
|
15
|
+
| { kind: 'end'; finishReason: string };
|
|
16
|
+
|
|
17
|
+
interface ResolvedDurableStream {
|
|
18
|
+
key: string;
|
|
19
|
+
maxBatchParts: number;
|
|
20
|
+
maxBatchDelayMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveDurableStream(options: DurableStreamOptions | undefined): ResolvedDurableStream | undefined {
|
|
24
|
+
if (options === undefined) return undefined;
|
|
25
|
+
const config = typeof options === 'string' ? { key: options } : options;
|
|
26
|
+
return { key: config.key, maxBatchParts: config.maxBatchParts ?? 20, maxBatchDelayMs: config.maxBatchDelayMs ?? 25 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stepInfo(): { step: number; attempt: number } {
|
|
30
|
+
return { step: DBOS.stepID ?? -1, attempt: DBOS.stepStatus?.currentAttempt ?? 1 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Only the parts toUIChunk renders; anything else would be written and never read.
|
|
34
|
+
function isContentPart(part: LanguageModelV4StreamPart): boolean {
|
|
35
|
+
switch (part.type) {
|
|
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':
|
|
48
|
+
return true;
|
|
49
|
+
default:
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Bytes become base64 so a file part stays compact in Postgres.
|
|
55
|
+
function encodePart(part: LanguageModelV4StreamPart): LanguageModelV4StreamPart {
|
|
56
|
+
if (part.type === 'file' && part.data.type === 'data' && part.data.data instanceof Uint8Array) {
|
|
57
|
+
return { ...part, data: { type: 'data', data: Buffer.from(part.data.data).toString('base64') } };
|
|
58
|
+
}
|
|
59
|
+
return part;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A transient write error (the SDK already retries offset conflicts) gets a few attempts before it fails the model call.
|
|
63
|
+
async function writeWithRetry(key: string, record: DurableStreamRecord): Promise<void> {
|
|
64
|
+
for (let attempt = 1; ; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
return await DBOS.writeStream(key, record);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (attempt === 3) throw error;
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Batches a live model step's parts into step-scope stream writes; nothing is written on replay because the step body does not run. */
|
|
75
|
+
export class ModelStreamWriter {
|
|
76
|
+
private pending: LanguageModelV4StreamPart[] = [];
|
|
77
|
+
private chain: Promise<void> = Promise.resolve();
|
|
78
|
+
private failure: unknown;
|
|
79
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
private readonly step = DBOS.stepID ?? -1;
|
|
81
|
+
// Unique per execution of the step, so a recovered run's re-execution is distinguishable from the crashed one.
|
|
82
|
+
private readonly attempt = randomUUID();
|
|
83
|
+
|
|
84
|
+
constructor(private readonly config: ResolvedDurableStream) {}
|
|
85
|
+
|
|
86
|
+
push(part: LanguageModelV4StreamPart): void {
|
|
87
|
+
if (!isContentPart(part)) return;
|
|
88
|
+
this.pending.push(encodePart(part));
|
|
89
|
+
if (this.pending.length >= this.config.maxBatchParts) this.flush();
|
|
90
|
+
else this.timer ??= setTimeout(() => this.flush(), this.config.maxBatchDelayMs);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flushes, records how the call ended, and resolves once every write is durable; a write that failed after retries fails the call here. */
|
|
94
|
+
async end(outcome: { finishReason: LanguageModelV4FinishReason } | { aborted: true }): Promise<void> {
|
|
95
|
+
this.flush();
|
|
96
|
+
this.write({ kind: 'model-end', step: this.step, attempt: this.attempt, ...outcome });
|
|
97
|
+
await this.chain;
|
|
98
|
+
if (this.failure !== undefined) throw this.failure;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** After a failure: flush what streamed so the record matches what the consumer saw; the stream's end then comes from the workflow's status. */
|
|
102
|
+
async abandon(): Promise<void> {
|
|
103
|
+
this.flush();
|
|
104
|
+
await this.chain;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private flush(): void {
|
|
108
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
109
|
+
this.timer = undefined;
|
|
110
|
+
if (this.pending.length === 0) return;
|
|
111
|
+
const parts = this.pending;
|
|
112
|
+
this.pending = [];
|
|
113
|
+
this.write({ kind: 'model', step: this.step, attempt: this.attempt, parts });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Every link has a handler, so a rejection can never sit unobserved; after one failure later writes are skipped.
|
|
117
|
+
private write(record: DurableStreamRecord): void {
|
|
118
|
+
this.chain = this.chain
|
|
119
|
+
.then(() => (this.failure === undefined ? writeWithRetry(this.config.key, record) : undefined))
|
|
120
|
+
.catch((error: unknown) => {
|
|
121
|
+
this.failure ??= error;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Records a tool call's outcome from inside its step. */
|
|
127
|
+
export function writeToolRecord(key: string, toolCallId: string, outcome: { output: unknown } | { errorText: string }): Promise<void> {
|
|
128
|
+
const record: DurableStreamRecord = { kind: 'tool', ...stepInfo(), toolCallId, ...outcome };
|
|
129
|
+
return DBOS.writeStream(key, record);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Appends UI message chunks to a durable stream. From a step the write is cheap and at-least-once, so give data parts
|
|
134
|
+
* stable ids; from workflow code it is a checkpointed step, so the number of calls must be deterministic.
|
|
135
|
+
*/
|
|
136
|
+
export function writeDurableStream(key: string, chunks: UIMessageChunk[]): Promise<void> {
|
|
137
|
+
const status = DBOS.stepStatus;
|
|
138
|
+
const record: DurableStreamRecord = { kind: 'ui', step: status?.stepID, attempt: status?.currentAttempt, chunks };
|
|
139
|
+
return DBOS.writeStream(key, record);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Marks the end of the turn explicitly and closes the stream; without it the reader infers the end from the last model call or the workflow's status. */
|
|
143
|
+
export async function closeDurableStream(key: string, finishReason = 'stop'): Promise<void> {
|
|
144
|
+
const record: DurableStreamRecord = { kind: 'end', finishReason };
|
|
145
|
+
await DBOS.writeStream(key, record);
|
|
146
|
+
await DBOS.closeStream(key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What the reader needs from DBOS: the `DBOS` class in a launched process, or a `DBOSClient` anywhere else. */
|
|
150
|
+
export interface DurableStreamSource {
|
|
151
|
+
readStream<T>(workflowID: string, key: string, options?: { offset?: number }): AsyncGenerator<T, void, unknown>;
|
|
152
|
+
readStreamOffset<T>(workflowID: string, key: string, offset: number, options?: { timeoutSeconds?: number }): Promise<T>;
|
|
153
|
+
retrieveWorkflow(workflowID: string): { getStatus(): Promise<{ status: string; error?: unknown } | null> };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ReadDurableStreamOptions {
|
|
157
|
+
workflowID: string;
|
|
158
|
+
key: string;
|
|
159
|
+
/** Id for the `start` chunk; omitted on a resume (`offset` > 0). */
|
|
160
|
+
messageId?: string;
|
|
161
|
+
/** Number of records already consumed, from the last `data-dbos-offset` chunk. */
|
|
162
|
+
offset?: number;
|
|
163
|
+
/** Defaults to `DBOS`; pass a `DBOSClient` to read from a process that has not launched DBOS. */
|
|
164
|
+
client?: DurableStreamSource;
|
|
165
|
+
/** Emit reasoning parts (default true, as in the AI SDK). */
|
|
166
|
+
sendReasoning?: boolean;
|
|
167
|
+
/** Emit source parts (default false, as in the AI SDK). */
|
|
168
|
+
sendSources?: boolean;
|
|
169
|
+
/** Text sent to clients for a workflow or tool error; defaults to a generic message, as in the AI SDK, so server details stay private. */
|
|
170
|
+
onError?: (error: unknown) => string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Reads a durable stream as AI SDK UI message chunks, live or after the fact, resuming from `offset`. */
|
|
174
|
+
export function readDurableStream(options: ReadDurableStreamOptions): ReadableStream<UIMessageChunk> {
|
|
175
|
+
const iterator = uiChunks(options);
|
|
176
|
+
return new ReadableStream<UIMessageChunk>({
|
|
177
|
+
async pull(controller) {
|
|
178
|
+
const { value, done } = await iterator.next();
|
|
179
|
+
if (done) controller.close();
|
|
180
|
+
else controller.enqueue(value);
|
|
181
|
+
},
|
|
182
|
+
async cancel() {
|
|
183
|
+
await iterator.return(undefined);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function* uiChunks(options: ReadDurableStreamOptions): AsyncGenerator<UIMessageChunk> {
|
|
189
|
+
const { workflowID, key, sendReasoning = true, sendSources = false, onError = () => 'An error occurred.' } = options;
|
|
190
|
+
const client: DurableStreamSource = options.client ?? DBOS;
|
|
191
|
+
const state: ReaderState = {
|
|
192
|
+
offset: options.offset ?? 0,
|
|
193
|
+
resumed: (options.offset ?? 0) > 0,
|
|
194
|
+
openParts: new Map(),
|
|
195
|
+
ended: false,
|
|
196
|
+
};
|
|
197
|
+
if (state.offset === 0) yield { type: 'start', messageId: options.messageId };
|
|
198
|
+
const emit = (record: DurableStreamRecord) => emitRecord(state, record, { sendReasoning, sendSources, onError });
|
|
199
|
+
|
|
200
|
+
// Phase 1: everything already stored, one value per query until an offset is empty; a superseded attempt is skipped whole.
|
|
201
|
+
const history: DurableStreamRecord[] = [];
|
|
202
|
+
for (;;) {
|
|
203
|
+
try {
|
|
204
|
+
history.push(await client.readStreamOffset<DurableStreamRecord>(workflowID, key, state.offset + history.length, { timeoutSeconds: 0 }));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!DBOSErrors.isStreamTimeoutError(error)) throw error;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const finalAttempt = new Map<number, string>();
|
|
211
|
+
for (const record of history) {
|
|
212
|
+
if (record.kind === 'model' || record.kind === 'model-end') finalAttempt.set(record.step, record.attempt);
|
|
213
|
+
}
|
|
214
|
+
for (const record of history) {
|
|
215
|
+
const stale = (record.kind === 'model' || record.kind === 'model-end') && finalAttempt.get(record.step) !== record.attempt;
|
|
216
|
+
yield* stale ? skipRecord(state) : emit(record);
|
|
217
|
+
if (state.ended) return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Phase 2: live; a re-executed step shows up as a new attempt and is handed off in place.
|
|
221
|
+
for await (const record of client.readStream<DurableStreamRecord>(workflowID, key, { offset: state.offset })) {
|
|
222
|
+
yield* emit(record);
|
|
223
|
+
if (state.ended) return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// No end record: the workflow's status decides how the turn ended (a stream closed while it still runs counts as finished).
|
|
227
|
+
const status = (await client.retrieveWorkflow(workflowID).getStatus())?.status;
|
|
228
|
+
yield* closeStep(state);
|
|
229
|
+
if (status === StatusString.CANCELLED) {
|
|
230
|
+
yield { type: 'abort' };
|
|
231
|
+
} else if (status === undefined || status === StatusString.SUCCESS || status === StatusString.PENDING || status === StatusString.ENQUEUED) {
|
|
232
|
+
// The AI SDK's finish schema has no 'unknown'; a turn with no model call ends with the reason omitted.
|
|
233
|
+
yield state.finishReason === undefined ? { type: 'finish' } : { type: 'finish', finishReason: state.finishReason as UIFinishReason };
|
|
234
|
+
} else {
|
|
235
|
+
const error = (await client.retrieveWorkflow(workflowID).getStatus())?.error;
|
|
236
|
+
yield { type: 'error', errorText: onError(error ?? new Error('The workflow ended before the response completed.')) };
|
|
237
|
+
yield { type: 'finish', finishReason: 'error' };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface ReaderState {
|
|
242
|
+
offset: number;
|
|
243
|
+
resumed: boolean;
|
|
244
|
+
openStep?: number;
|
|
245
|
+
openAttempt?: string;
|
|
246
|
+
// Text/reasoning parts of the open attempt that have started but not ended, by UI part id.
|
|
247
|
+
openParts: Map<string, 'text' | 'reasoning'>;
|
|
248
|
+
finishReason?: string;
|
|
249
|
+
ended: boolean;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function offsetChunk(state: ReaderState): UIMessageChunk {
|
|
253
|
+
return { type: 'data-dbos-offset', data: { offset: state.offset }, transient: true } as UIMessageChunk;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function* skipRecord(state: ReaderState): Generator<UIMessageChunk> {
|
|
257
|
+
state.offset += 1;
|
|
258
|
+
yield offsetChunk(state);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function* closeStep(state: ReaderState): Generator<UIMessageChunk> {
|
|
262
|
+
if (state.openStep !== undefined) yield { type: 'finish-step' };
|
|
263
|
+
state.openStep = undefined;
|
|
264
|
+
state.openAttempt = undefined;
|
|
265
|
+
state.openParts.clear();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A live re-execution of the open step: end the stale attempt's parts and tell the client which ones to discard.
|
|
269
|
+
function* supersede(state: ReaderState, attempt: string): Generator<UIMessageChunk> {
|
|
270
|
+
for (const [id, kind] of state.openParts) yield { type: kind === 'text' ? 'text-end' : 'reasoning-end', id };
|
|
271
|
+
yield {
|
|
272
|
+
type: 'data-dbos-superseded',
|
|
273
|
+
data: { attempt: state.openAttempt, parts: [...state.openParts.keys()] },
|
|
274
|
+
transient: true,
|
|
275
|
+
} as UIMessageChunk;
|
|
276
|
+
state.openParts.clear();
|
|
277
|
+
state.openAttempt = attempt;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function* emitRecord(
|
|
281
|
+
state: ReaderState,
|
|
282
|
+
record: DurableStreamRecord,
|
|
283
|
+
filter: { sendReasoning: boolean; sendSources: boolean; onError: (error: unknown) => string },
|
|
284
|
+
): Generator<UIMessageChunk> {
|
|
285
|
+
state.offset += 1;
|
|
286
|
+
switch (record.kind) {
|
|
287
|
+
case 'model': {
|
|
288
|
+
if (state.openStep !== record.step) {
|
|
289
|
+
yield* closeStep(state);
|
|
290
|
+
if (!state.resumed) yield { type: 'start-step' };
|
|
291
|
+
state.resumed = false;
|
|
292
|
+
state.openStep = record.step;
|
|
293
|
+
state.openAttempt = record.attempt;
|
|
294
|
+
} else if (state.openAttempt !== record.attempt) {
|
|
295
|
+
yield* supersede(state, record.attempt);
|
|
296
|
+
}
|
|
297
|
+
for (const part of record.parts) {
|
|
298
|
+
if (!filter.sendReasoning && part.type.startsWith('reasoning-')) continue;
|
|
299
|
+
if (!filter.sendSources && part.type === 'source') continue;
|
|
300
|
+
const chunk = toUIChunk(part, record.attempt);
|
|
301
|
+
if (!chunk) continue;
|
|
302
|
+
if (chunk.type === 'text-start' || chunk.type === 'reasoning-start') state.openParts.set(chunk.id, chunk.type === 'text-start' ? 'text' : 'reasoning');
|
|
303
|
+
if (chunk.type === 'text-end' || chunk.type === 'reasoning-end') state.openParts.delete(chunk.id);
|
|
304
|
+
yield chunk;
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case 'model-end':
|
|
309
|
+
// The stream outlives the call: the workflow may run more calls, so only its end (or closeDurableStream) ends the turn.
|
|
310
|
+
if (record.attempt === state.openAttempt) state.finishReason = record.aborted ? 'other' : record.finishReason?.unified;
|
|
311
|
+
break;
|
|
312
|
+
case 'tool':
|
|
313
|
+
// Local tool errors are masked like the AI SDK does; provider-executed ones (in model records) pass through verbatim.
|
|
314
|
+
yield record.errorText !== undefined
|
|
315
|
+
? { type: 'tool-output-error', toolCallId: record.toolCallId, errorText: filter.onError(new Error(record.errorText)) }
|
|
316
|
+
: { type: 'tool-output-available', toolCallId: record.toolCallId, output: record.output };
|
|
317
|
+
break;
|
|
318
|
+
case 'ui':
|
|
319
|
+
yield* record.chunks;
|
|
320
|
+
break;
|
|
321
|
+
case 'end':
|
|
322
|
+
// The terminal chunk comes last, so the offset goes out first.
|
|
323
|
+
yield offsetChunk(state);
|
|
324
|
+
yield* closeStep(state);
|
|
325
|
+
yield { type: 'finish', finishReason: record.finishReason as UIFinishReason };
|
|
326
|
+
state.ended = true;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
yield offsetChunk(state);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
type UIFinishReason = Extract<UIMessageChunk, { type: 'finish' }>['finishReason'];
|
|
333
|
+
|
|
334
|
+
function parseInput(input: string): unknown {
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(input);
|
|
337
|
+
} catch {
|
|
338
|
+
return input;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Text and reasoning ids are only unique within one model call; the attempt id keeps calls, and re-executions, apart in one message.
|
|
343
|
+
function toUIChunk(part: LanguageModelV4StreamPart, attempt: string): UIMessageChunk | undefined {
|
|
344
|
+
const id = 'id' in part ? `${attempt}:${part.id}` : '';
|
|
345
|
+
switch (part.type) {
|
|
346
|
+
case 'text-start':
|
|
347
|
+
return { type: 'text-start', id, providerMetadata: part.providerMetadata };
|
|
348
|
+
case 'text-delta':
|
|
349
|
+
return { type: 'text-delta', id, delta: part.delta, providerMetadata: part.providerMetadata };
|
|
350
|
+
case 'text-end':
|
|
351
|
+
return { type: 'text-end', id, providerMetadata: part.providerMetadata };
|
|
352
|
+
case 'reasoning-start':
|
|
353
|
+
return { type: 'reasoning-start', id, providerMetadata: part.providerMetadata };
|
|
354
|
+
case 'reasoning-delta':
|
|
355
|
+
return { type: 'reasoning-delta', id, delta: part.delta, providerMetadata: part.providerMetadata };
|
|
356
|
+
case 'reasoning-end':
|
|
357
|
+
return { type: 'reasoning-end', id, providerMetadata: part.providerMetadata };
|
|
358
|
+
case 'tool-input-start':
|
|
359
|
+
return {
|
|
360
|
+
type: 'tool-input-start',
|
|
361
|
+
toolCallId: part.id,
|
|
362
|
+
toolName: part.toolName,
|
|
363
|
+
providerExecuted: part.providerExecuted,
|
|
364
|
+
dynamic: part.dynamic,
|
|
365
|
+
title: part.title,
|
|
366
|
+
providerMetadata: part.providerMetadata,
|
|
367
|
+
};
|
|
368
|
+
case 'tool-input-delta':
|
|
369
|
+
return { type: 'tool-input-delta', toolCallId: part.id, inputTextDelta: part.delta };
|
|
370
|
+
case 'tool-call':
|
|
371
|
+
return {
|
|
372
|
+
type: 'tool-input-available',
|
|
373
|
+
toolCallId: part.toolCallId,
|
|
374
|
+
toolName: part.toolName,
|
|
375
|
+
input: parseInput(part.input),
|
|
376
|
+
providerExecuted: part.providerExecuted,
|
|
377
|
+
dynamic: part.dynamic,
|
|
378
|
+
providerMetadata: part.providerMetadata,
|
|
379
|
+
};
|
|
380
|
+
case 'tool-result':
|
|
381
|
+
return part.isError
|
|
382
|
+
? { type: 'tool-output-error', toolCallId: part.toolCallId, errorText: JSON.stringify(part.result), providerExecuted: true, dynamic: part.dynamic }
|
|
383
|
+
: { type: 'tool-output-available', toolCallId: part.toolCallId, output: part.result, providerExecuted: true, dynamic: part.dynamic, preliminary: part.preliminary };
|
|
384
|
+
case 'source':
|
|
385
|
+
return part.sourceType === 'url'
|
|
386
|
+
? { type: 'source-url', sourceId: part.id, url: part.url, title: part.title, providerMetadata: part.providerMetadata }
|
|
387
|
+
: { type: 'source-document', sourceId: part.id, mediaType: part.mediaType, title: part.title, filename: part.filename, providerMetadata: part.providerMetadata };
|
|
388
|
+
case 'file': {
|
|
389
|
+
const url = part.data.type === 'url' ? String(part.data.url) : part.data.type === 'data' ? `data:${part.mediaType};base64,${String(part.data.data)}` : undefined;
|
|
390
|
+
return url === undefined ? undefined : { type: 'file', url, mediaType: part.mediaType, providerMetadata: part.providerMetadata };
|
|
391
|
+
}
|
|
392
|
+
default:
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
|
-
export { durableCalls, durableEmbeddingCalls, durableImageCalls } from './middleware';
|
|
1
|
+
export { durableCalls, DurableCallsOptions, durableEmbeddingCalls, durableImageCalls } from './middleware';
|
|
2
2
|
export { durableMCPTools, DurableMCPToolsOptions, MCPClientLike } from './mcp';
|
|
3
|
+
export { durableTools, DurableToolsOptions } from './tools';
|
|
4
|
+
export {
|
|
5
|
+
closeDurableStream,
|
|
6
|
+
DurableStreamOptions,
|
|
7
|
+
DurableStreamRecord,
|
|
8
|
+
DurableStreamSource,
|
|
9
|
+
ReadDurableStreamOptions,
|
|
10
|
+
readDurableStream,
|
|
11
|
+
writeDurableStream,
|
|
12
|
+
} from './durable-stream';
|
|
13
|
+
export { agentTool, AgentTool, AgentToolOptions } from './agent-tool';
|
package/src/internal.ts
CHANGED
|
@@ -11,6 +11,20 @@ export function assertNotInTransaction(operation: string): void {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Run fn as a durable step named `name` inside a workflow; elsewhere call it directly.
|
|
15
|
+
export function runDurableStep<T>(name: string, fn: () => Promise<T>, config: StepConfig): Promise<T> {
|
|
16
|
+
assertNotInTransaction(name);
|
|
17
|
+
if (!isInWorkflowFunction()) return fn();
|
|
18
|
+
// Restore the AI SDK error identity a replay revival strips, so the SDK's retry/catch logic behaves the same.
|
|
19
|
+
return DBOS.runStep(fn, { ...config, name }).catch((error: unknown) => {
|
|
20
|
+
throw restoreAISDKErrorIdentity(error);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
25
|
+
return typeof (value as AsyncIterable<unknown> | null | undefined)?.[Symbol.asyncIterator] === 'function';
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
// Aborts/timeouts are deliberate cancellations, never transient; retrying just re-runs an already-cancelled call.
|
|
15
29
|
function isAbortError(error: unknown): boolean {
|
|
16
30
|
const name = (error as { name?: unknown } | null)?.name;
|
package/src/mcp.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { StepConfig } from '@dbos-inc/dbos-sdk';
|
|
2
2
|
import type { ToolSet } from 'ai' with { 'resolution-mode': 'import' };
|
|
3
|
-
import {
|
|
3
|
+
import { isAsyncIterable, runDurableStep, withErrorClassification } from './internal';
|
|
4
|
+
import { writeToolRecord } from './durable-stream';
|
|
4
5
|
|
|
5
6
|
// Structural type for an MCP client (e.g. from @ai-sdk/mcp) — deliberately loose: the AI SDK ecosystem
|
|
6
7
|
// exact-pins @ai-sdk/provider-utils, so precise Tool types fail to match across skewed copies.
|
|
@@ -23,6 +24,8 @@ interface MCPToolLike {
|
|
|
23
24
|
export interface DurableMCPToolsOptions extends StepConfig {
|
|
24
25
|
/** Forwarded to client.tools() on listing and on each call (e.g. { schemas } for subsetting and output schemas). */
|
|
25
26
|
toolOptions?: unknown;
|
|
27
|
+
/** Write each tool call's output (or error) to this durable stream from inside its step. */
|
|
28
|
+
durableStream?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
interface DurableToolDef {
|
|
@@ -36,10 +39,6 @@ interface DurableToolDef {
|
|
|
36
39
|
|
|
37
40
|
type ToolModelOutput = Awaited<ReturnType<NonNullable<ToolSet[string]['toModelOutput']>>>;
|
|
38
41
|
|
|
39
|
-
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
40
|
-
return typeof (value as AsyncIterable<unknown> | null | undefined)?.[Symbol.asyncIterator] === 'function';
|
|
41
|
-
}
|
|
42
|
-
|
|
43
42
|
// Mirror of @ai-sdk/mcp's toModelOutput: MCP content becomes model content (text stays text, images become files).
|
|
44
43
|
function mcpToolOutput(output: unknown): ToolModelOutput {
|
|
45
44
|
const result = output as { content?: unknown };
|
|
@@ -66,17 +65,11 @@ function mcpToolOutput(output: unknown): ToolModelOutput {
|
|
|
66
65
|
* checkpointed so a recovered workflow replays results instead of re-invoking the tool.
|
|
67
66
|
*/
|
|
68
67
|
export async function durableMCPTools(client: MCPClientLike, options: DurableMCPToolsOptions = {}): Promise<ToolSet> {
|
|
69
|
-
const { toolOptions, ...stepOptions } = options;
|
|
68
|
+
const { toolOptions, durableStream, ...stepOptions } = options;
|
|
70
69
|
const stepConfig = withErrorClassification(stepOptions);
|
|
71
70
|
const { asSchema, dynamicTool, jsonSchema } = await import('ai');
|
|
72
|
-
const run = <T>(name: string, fn: () => Promise<T>, config: StepConfig = stepConfig): Promise<T> =>
|
|
73
|
-
|
|
74
|
-
if (!isInWorkflowFunction()) return fn();
|
|
75
|
-
// Restore the AI SDK error identity a replay revival strips, so the SDK's retry/catch logic behaves the same.
|
|
76
|
-
return DBOS.runStep(fn, { ...config, name }).catch((error: unknown) => {
|
|
77
|
-
throw restoreAISDKErrorIdentity(error);
|
|
78
|
-
});
|
|
79
|
-
};
|
|
71
|
+
const run = <T>(name: string, fn: () => Promise<T>, config: StepConfig = stepConfig): Promise<T> =>
|
|
72
|
+
runDurableStep(name, fn, config);
|
|
80
73
|
|
|
81
74
|
// Checkpoint the tool list as plain JSON schemas, so replay reconstructs tools without the live client.
|
|
82
75
|
const listed = await run('mcp.listTools', async () => {
|
|
@@ -108,25 +101,35 @@ export async function durableMCPTools(client: MCPClientLike, options: DurableMCP
|
|
|
108
101
|
toModelOutput: def.convertsOutput ? ({ output }) => mcpToolOutput(output) : undefined,
|
|
109
102
|
// Re-fetch the live tool inside the step (its execute closure can't be checkpointed); replay returns the recorded result.
|
|
110
103
|
execute: (input: unknown, execOptions) => {
|
|
111
|
-
const signal = (execOptions as { abortSignal?: AbortSignal
|
|
104
|
+
const { abortSignal: signal, toolCallId } = (execOptions ?? {}) as { abortSignal?: AbortSignal; toolCallId?: string };
|
|
112
105
|
// An aborted consumer is done with this call, whatever the failure looks like; a retry would re-run a cancelled side effect.
|
|
113
106
|
const callConfig: StepConfig = {
|
|
114
107
|
...stepConfig,
|
|
115
108
|
shouldRetry: async (error: unknown) =>
|
|
116
109
|
!signal?.aborted && (stepConfig.shouldRetry ? await stepConfig.shouldRetry(error) : true),
|
|
117
110
|
};
|
|
111
|
+
// The tool call id comes from the checkpointed model result, so a reordered parallel step fails replay instead of swapping results.
|
|
118
112
|
return run(
|
|
119
|
-
`mcp.tool.${name}`,
|
|
113
|
+
`mcp.tool.${name}.${toolCallId ?? 'call'}`,
|
|
120
114
|
async () => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
115
|
+
let output: unknown;
|
|
116
|
+
try {
|
|
117
|
+
const tool = (await client.tools(toolOptions))[name] as MCPToolLike | undefined;
|
|
118
|
+
if (typeof tool?.execute !== 'function') throw new Error(`MCP tool "${name}" is not executable.`);
|
|
119
|
+
output = await tool.execute(input, execOptions);
|
|
120
|
+
// A streaming execute can't checkpoint mid-flight; drain it and record the final value (the last yield).
|
|
121
|
+
if (isAsyncIterable(output)) {
|
|
122
|
+
let last: unknown;
|
|
123
|
+
for await (last of output);
|
|
124
|
+
output = last;
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (durableStream && toolCallId) {
|
|
128
|
+
await writeToolRecord(durableStream, toolCallId, { errorText: error instanceof Error ? error.message : String(error) });
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
129
131
|
}
|
|
132
|
+
if (durableStream && toolCallId) await writeToolRecord(durableStream, toolCallId, { output });
|
|
130
133
|
return output;
|
|
131
134
|
},
|
|
132
135
|
callConfig,
|