@convex-dev/agent 0.7.0 → 0.7.2
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 +5 -8
- package/dist/client/messages.d.ts +10 -0
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +1 -0
- package/dist/client/messages.js.map +1 -1
- package/dist/component/_generated/component.d.ts +15 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/files.d.ts +6 -4
- package/dist/component/files.d.ts.map +1 -1
- package/dist/component/files.js +53 -42
- package/dist/component/files.js.map +1 -1
- package/dist/component/messages.d.ts +340 -338
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +80 -16
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +782 -626
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +12 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/streams.d.ts +16 -2
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +86 -30
- package/dist/component/streams.js.map +1 -1
- package/dist/component/vector/index.d.ts +9 -9
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/tables.d.ts +5 -5
- package/dist/component/vector/tables.d.ts.map +1 -1
- package/dist/component/vector/tables.js +1 -1
- package/dist/component/vector/tables.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
- package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
- package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
- package/dist/validators.d.ts +1952 -1952
- package/dist/vercel/UIMessages.js +1 -1
- package/dist/vercel/UIMessages.js.map +1 -1
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- package/dist/vercel/client/files.d.ts.map +1 -1
- package/dist/vercel/client/files.js +51 -27
- package/dist/vercel/client/files.js.map +1 -1
- package/dist/vercel/client/messages.d.ts +50 -32
- package/dist/vercel/client/messages.d.ts.map +1 -1
- package/dist/vercel/client/messages.js +2 -0
- package/dist/vercel/client/messages.js.map +1 -1
- package/dist/vercel/client/search.d.ts +475 -32
- package/dist/vercel/client/search.d.ts.map +1 -1
- package/dist/vercel/client/search.js +34 -9
- package/dist/vercel/client/search.js.map +1 -1
- package/dist/vercel/client/start.d.ts.map +1 -1
- package/dist/vercel/client/start.js +1 -0
- package/dist/vercel/client/start.js.map +1 -1
- package/dist/vercel/client/streamText.d.ts +1 -2
- package/dist/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +49 -17
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +318 -301
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +56 -21
- package/dist/vercel/client/streaming.js.map +1 -1
- package/dist/vercel/client/types.d.ts +2 -0
- package/dist/vercel/client/types.d.ts.map +1 -1
- package/dist/vercel/client/types.js.map +1 -1
- package/dist/vercel/client/utils.d.ts +9 -5
- package/dist/vercel/client/utils.d.ts.map +1 -1
- package/dist/vercel/client/utils.js +13 -13
- package/dist/vercel/client/utils.js.map +1 -1
- package/dist/vercel/fileMaterialization.d.ts +24 -0
- package/dist/vercel/fileMaterialization.d.ts.map +1 -0
- package/dist/vercel/fileMaterialization.js +118 -0
- package/dist/vercel/fileMaterialization.js.map +1 -0
- package/dist/vercel/index.d.ts +91 -90
- package/dist/vercel/index.d.ts.map +1 -1
- package/dist/vercel/index.js +2 -0
- package/dist/vercel/index.js.map +1 -1
- package/dist/vercel/mapping.d.ts.map +1 -1
- package/dist/vercel/mapping.js +7 -1
- package/dist/vercel/mapping.js.map +1 -1
- package/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +18 -2
- package/src/component/files.test.ts +81 -6
- package/src/component/files.ts +69 -41
- package/src/component/messages.test.ts +496 -0
- package/src/component/messages.ts +105 -15
- package/src/component/schema.ts +16 -2
- package/src/component/streams.test.ts +109 -0
- package/src/component/streams.ts +123 -35
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
- package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
- package/src/vercel/UIMessages.ts +1 -1
- package/src/vercel/client/files.test.ts +56 -0
- package/src/vercel/client/files.ts +53 -28
- package/src/vercel/client/index.test.ts +47 -0
- package/src/vercel/client/messages.ts +20 -0
- package/src/vercel/client/search.test.ts +183 -8
- package/src/vercel/client/search.ts +46 -12
- package/src/vercel/client/start.ts +1 -0
- package/src/vercel/client/streamText.test.ts +113 -17
- package/src/vercel/client/streamText.ts +55 -28
- package/src/vercel/client/streaming.test.ts +142 -7
- package/src/vercel/client/streaming.ts +73 -19
- package/src/vercel/client/types.ts +2 -0
- package/src/vercel/client/utils.test.ts +97 -0
- package/src/vercel/client/utils.ts +15 -15
- package/src/vercel/fileMaterialization.ts +174 -0
- package/src/vercel/index.ts +2 -0
- package/src/vercel/mapping.test.ts +117 -0
- package/src/vercel/mapping.ts +15 -1
- package/src/vercel/toUIMessages.test.ts +7 -1
|
@@ -25,20 +25,24 @@ import { startGeneration } from "./start.js";
|
|
|
25
25
|
import type { Agent } from "../index.js";
|
|
26
26
|
import { getModelName, getProviderName } from "../../shared.js";
|
|
27
27
|
import { errorToString, willContinue } from "./utils.js";
|
|
28
|
+
import { materializeUIMessageChunkFiles } from "../fileMaterialization.js";
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
export async function runAbortCleanup(cleanup: {
|
|
30
|
+
export async function runStreamCleanup(cleanup: {
|
|
31
31
|
failCall: () => Promise<void>;
|
|
32
32
|
failStreamer: () => Promise<void>;
|
|
33
33
|
onAbort?: () => PromiseLike<void> | void;
|
|
34
34
|
}): Promise<void> {
|
|
35
35
|
const results = await Promise.allSettled([
|
|
36
|
-
cleanup.failCall(),
|
|
37
|
-
cleanup.failStreamer(),
|
|
36
|
+
Promise.resolve().then(() => cleanup.failCall()),
|
|
37
|
+
Promise.resolve().then(() => cleanup.failStreamer()),
|
|
38
38
|
]);
|
|
39
|
-
await
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const [abortResult] = await Promise.allSettled([
|
|
40
|
+
Promise.resolve().then(() => cleanup.onAbort?.()),
|
|
41
|
+
]);
|
|
42
|
+
const failure = [...results, abortResult].find(
|
|
43
|
+
(result) => result.status === "rejected",
|
|
44
|
+
);
|
|
45
|
+
if (failure?.status === "rejected") throw failure.reason;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -102,15 +106,11 @@ export async function streamText<
|
|
|
102
106
|
Tools,
|
|
103
107
|
object,
|
|
104
108
|
RUNTIME_CONTEXT
|
|
105
|
-
>(
|
|
106
|
-
ctx,
|
|
107
|
-
component,
|
|
108
|
-
streamTextArgs,
|
|
109
|
-
options,
|
|
110
|
-
"streamText",
|
|
111
|
-
);
|
|
109
|
+
>(ctx, component, streamTextArgs, options, "streamText");
|
|
112
110
|
|
|
113
111
|
const steps: StepResult<Tools, RUNTIME_CONTEXT>[] = [];
|
|
112
|
+
let firstStreamError: string | undefined;
|
|
113
|
+
let streamCleanupFailure: { error: unknown } | undefined;
|
|
114
114
|
let initialResponseMessages: ModelMessage[] = [];
|
|
115
115
|
let initialResponseMessagesSaved = false;
|
|
116
116
|
const responseMessagesForStep = (
|
|
@@ -152,6 +152,8 @@ export async function streamText<
|
|
|
152
152
|
: undefined,
|
|
153
153
|
onAsyncAbort: call.fail,
|
|
154
154
|
compress: compressUIMessageChunks,
|
|
155
|
+
materialize: (parts) =>
|
|
156
|
+
materializeUIMessageChunkFiles(ctx, component, parts),
|
|
155
157
|
abortSignal: args.abortSignal,
|
|
156
158
|
},
|
|
157
159
|
{
|
|
@@ -177,15 +179,26 @@ export async function streamText<
|
|
|
177
179
|
),
|
|
178
180
|
onError: async (error) => {
|
|
179
181
|
console.error("onError", error);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
const reason = (firstStreamError ??= errorToString(error.error));
|
|
183
|
+
try {
|
|
184
|
+
await runStreamCleanup({
|
|
185
|
+
failCall: () => call.fail(reason),
|
|
186
|
+
failStreamer: async () => streamer?.fail(reason),
|
|
187
|
+
});
|
|
188
|
+
} catch (cleanupError) {
|
|
189
|
+
streamCleanupFailure ??= { error: cleanupError };
|
|
190
|
+
console.error("Failed to clean up errored stream:", cleanupError);
|
|
191
|
+
}
|
|
182
192
|
return streamTextArgs.onError?.(error);
|
|
183
193
|
},
|
|
184
194
|
onAbort: async (event) => {
|
|
195
|
+
const providerTriggeredAbort =
|
|
196
|
+
firstStreamError !== undefined && !args.abortSignal?.aborted;
|
|
197
|
+
if (providerTriggeredAbort) return;
|
|
185
198
|
const reason = args.abortSignal?.reason
|
|
186
199
|
? errorToString(args.abortSignal.reason)
|
|
187
200
|
: "streamText aborted";
|
|
188
|
-
await
|
|
201
|
+
await runStreamCleanup({
|
|
189
202
|
failCall: () => call.fail(reason),
|
|
190
203
|
failStreamer: async () => streamer?.fail(reason),
|
|
191
204
|
onAbort: () => streamTextArgs.onAbort?.(event),
|
|
@@ -224,13 +237,17 @@ export async function streamText<
|
|
|
224
237
|
// returnImmediately path: streamText is about to return without
|
|
225
238
|
// awaiting consumption, so the deferred-save block below won't
|
|
226
239
|
// see this step. Save inline now (issue #265).
|
|
227
|
-
const finishStreamId = await streamer.getOrCreateStreamId(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
const finishStreamId = await streamer.getOrCreateStreamId({
|
|
241
|
+
ifAborted: "returnUndefined",
|
|
242
|
+
});
|
|
243
|
+
if (finishStreamId) {
|
|
244
|
+
await call.save(
|
|
245
|
+
{ step, responseMessages: responseMessagesForStep(step) },
|
|
246
|
+
false,
|
|
247
|
+
finishStreamId,
|
|
248
|
+
);
|
|
249
|
+
initialResponseMessagesSaved = true;
|
|
250
|
+
}
|
|
234
251
|
}
|
|
235
252
|
} else {
|
|
236
253
|
await call.save(
|
|
@@ -255,8 +272,11 @@ export async function streamText<
|
|
|
255
272
|
// If the stream errored (e.g. onStepFinish threw), the DeltaStreamer's
|
|
256
273
|
// finish() was never called, leaving the streaming message stuck in
|
|
257
274
|
// "streaming" state. Clean it up by marking it as aborted.
|
|
258
|
-
|
|
259
|
-
|
|
275
|
+
try {
|
|
276
|
+
await streamer?.fail(errorToString(e));
|
|
277
|
+
} catch (cleanupError) {
|
|
278
|
+
streamCleanupFailure ??= { error: cleanupError };
|
|
279
|
+
}
|
|
260
280
|
if (pendingFinalStep) {
|
|
261
281
|
try {
|
|
262
282
|
await call.save(pendingFinalStep, false);
|
|
@@ -269,10 +289,17 @@ export async function streamText<
|
|
|
269
289
|
}
|
|
270
290
|
}
|
|
271
291
|
|
|
292
|
+
if (streamCleanupFailure) throw streamCleanupFailure.error;
|
|
293
|
+
|
|
272
294
|
// If we deferred the final step save, do it now with atomic stream finish.
|
|
273
295
|
if (pendingFinalStep && streamer) {
|
|
274
|
-
const finishStreamId = await streamer.getOrCreateStreamId(
|
|
275
|
-
|
|
296
|
+
const finishStreamId = await streamer.getOrCreateStreamId({
|
|
297
|
+
ifAborted: "returnUndefined",
|
|
298
|
+
});
|
|
299
|
+
if (finishStreamId) {
|
|
300
|
+
await call.save(pendingFinalStep, false, finishStreamId);
|
|
301
|
+
}
|
|
302
|
+
pendingFinalStep = undefined;
|
|
276
303
|
}
|
|
277
304
|
const metadata: GenerationOutputMetadata = {
|
|
278
305
|
promptMessageId,
|
|
@@ -5,10 +5,7 @@ import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
|
5
5
|
import { streamText } from "ai";
|
|
6
6
|
import { components, initConvexTest } from "./setup.test.js";
|
|
7
7
|
import { mockModel } from "./mockModel.js";
|
|
8
|
-
import {
|
|
9
|
-
compressUIMessageChunks,
|
|
10
|
-
DeltaStreamer,
|
|
11
|
-
} from "./streaming.js";
|
|
8
|
+
import { compressUIMessageChunks, DeltaStreamer } from "./streaming.js";
|
|
12
9
|
import { getParts } from "../deltas.js";
|
|
13
10
|
import type { TestConvex } from "convex-test";
|
|
14
11
|
|
|
@@ -200,10 +197,71 @@ describe("DeltaStreamer", () => {
|
|
|
200
197
|
expect(streamer.abortController.signal.aborted).toBe(true);
|
|
201
198
|
await streamer.addParts(["ignored"]);
|
|
202
199
|
expect(streamer.streamId).toBeUndefined();
|
|
200
|
+
await expect(
|
|
201
|
+
streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
|
|
202
|
+
).resolves.toBeUndefined();
|
|
203
203
|
await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
|
|
204
204
|
"Cannot create a stream after it has been aborted",
|
|
205
205
|
);
|
|
206
|
+
await expect(streamer.getStreamId()).rejects.toThrow(
|
|
207
|
+
"Cannot create a stream after it has been aborted",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("preserves the public throwing behavior after an existing stream aborts", async () => {
|
|
213
|
+
await t.run(async (ctx) => {
|
|
214
|
+
const streamer = new DeltaStreamer<string>(
|
|
215
|
+
components.agent,
|
|
216
|
+
ctx,
|
|
217
|
+
{ ...defaultTestOptions },
|
|
218
|
+
{ ...testMetadata, threadId },
|
|
219
|
+
);
|
|
220
|
+
const streamId = await streamer.getStreamId();
|
|
221
|
+
|
|
222
|
+
await streamer.fail("provider error");
|
|
223
|
+
|
|
224
|
+
expect(streamer.streamId).toBe(streamId);
|
|
225
|
+
await expect(streamer.getStreamId()).rejects.toThrow(
|
|
226
|
+
"Cannot create a stream after it has been aborted",
|
|
227
|
+
);
|
|
228
|
+
await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
|
|
229
|
+
"Cannot create a stream after it has been aborted",
|
|
230
|
+
);
|
|
231
|
+
await expect(
|
|
232
|
+
streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
|
|
233
|
+
).resolves.toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("does not return an ID when abort wins during stream creation", async () => {
|
|
238
|
+
let resolveCreate!: (streamId: string) => void;
|
|
239
|
+
const creatingStream = new Promise<string>((resolve) => {
|
|
240
|
+
resolveCreate = resolve;
|
|
241
|
+
});
|
|
242
|
+
const runMutation = vi
|
|
243
|
+
.fn()
|
|
244
|
+
.mockImplementationOnce(() => creatingStream)
|
|
245
|
+
.mockResolvedValueOnce(true);
|
|
246
|
+
const streamer = new DeltaStreamer<string>(
|
|
247
|
+
components.agent,
|
|
248
|
+
{ runMutation } as unknown as MutationCtx,
|
|
249
|
+
{ ...defaultTestOptions },
|
|
250
|
+
{ ...testMetadata, threadId },
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const getting = streamer.getOrCreateStreamId({
|
|
254
|
+
ifAborted: "returnUndefined",
|
|
206
255
|
});
|
|
256
|
+
const failing = streamer.fail("provider error");
|
|
257
|
+
resolveCreate("stream-1");
|
|
258
|
+
|
|
259
|
+
await failing;
|
|
260
|
+
await expect(getting).resolves.toBeUndefined();
|
|
261
|
+
expect(streamer.streamId).toBe("stream-1");
|
|
262
|
+
await expect(streamer.getStreamId()).rejects.toThrow(
|
|
263
|
+
"Cannot create a stream after it has been aborted",
|
|
264
|
+
);
|
|
207
265
|
});
|
|
208
266
|
|
|
209
267
|
test("shares signal and fail cleanup while stream creation is in flight", async () => {
|
|
@@ -249,11 +307,57 @@ describe("DeltaStreamer", () => {
|
|
|
249
307
|
);
|
|
250
308
|
});
|
|
251
309
|
|
|
310
|
+
test("waits for signal cleanup when the source throws", async () => {
|
|
311
|
+
let resolveDelta!: (value: boolean) => void;
|
|
312
|
+
const deltaWrite = new Promise<boolean>((resolve) => {
|
|
313
|
+
resolveDelta = resolve;
|
|
314
|
+
});
|
|
315
|
+
const runMutation = vi
|
|
316
|
+
.fn()
|
|
317
|
+
.mockResolvedValueOnce("stream-1")
|
|
318
|
+
.mockImplementationOnce(() => deltaWrite)
|
|
319
|
+
.mockResolvedValueOnce(undefined);
|
|
320
|
+
const abortController = new AbortController();
|
|
321
|
+
const streamer = new DeltaStreamer<string>(
|
|
322
|
+
components.agent,
|
|
323
|
+
{ runMutation } as unknown as MutationCtx,
|
|
324
|
+
{ ...defaultTestOptions, abortSignal: abortController.signal },
|
|
325
|
+
{ ...testMetadata, threadId },
|
|
326
|
+
);
|
|
327
|
+
const sourceError = new Error("provider aborted");
|
|
328
|
+
const source = {
|
|
329
|
+
async *[Symbol.asyncIterator]() {
|
|
330
|
+
yield "chunk";
|
|
331
|
+
abortController.abort();
|
|
332
|
+
throw sourceError;
|
|
333
|
+
},
|
|
334
|
+
} as unknown as Parameters<typeof streamer.consumeStream>[0];
|
|
335
|
+
|
|
336
|
+
const consuming = streamer.consumeStream(source);
|
|
337
|
+
let settled = false;
|
|
338
|
+
void consuming.catch(() => {
|
|
339
|
+
settled = true;
|
|
340
|
+
});
|
|
341
|
+
await vi.waitFor(() => expect(runMutation).toHaveBeenCalledTimes(2));
|
|
342
|
+
expect(settled).toBe(false);
|
|
343
|
+
|
|
344
|
+
resolveDelta(true);
|
|
345
|
+
await expect(consuming).rejects.toBe(sourceError);
|
|
346
|
+
expect(runMutation).toHaveBeenNthCalledWith(
|
|
347
|
+
3,
|
|
348
|
+
components.agent.streams.abort,
|
|
349
|
+
{ streamId: "stream-1", reason: "abortSignal" },
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
252
353
|
test("aborts the component stream when a delta write fails", async () => {
|
|
354
|
+
const deltaFailure = {
|
|
355
|
+
error: { code: "provider_disconnected", message: "Provider dropped" },
|
|
356
|
+
};
|
|
253
357
|
const runMutation = vi
|
|
254
358
|
.fn()
|
|
255
359
|
.mockResolvedValueOnce("stream-1")
|
|
256
|
-
.mockRejectedValueOnce(
|
|
360
|
+
.mockRejectedValueOnce(deltaFailure)
|
|
257
361
|
.mockResolvedValueOnce(undefined);
|
|
258
362
|
let abortReason: string | undefined;
|
|
259
363
|
const streamer = new DeltaStreamer<string>(
|
|
@@ -271,11 +375,42 @@ describe("DeltaStreamer", () => {
|
|
|
271
375
|
await streamer.addParts(["A"]);
|
|
272
376
|
await streamer.finish();
|
|
273
377
|
|
|
274
|
-
expect(abortReason).toBe("
|
|
378
|
+
expect(abortReason).toBe("provider_disconnected: Provider dropped");
|
|
275
379
|
expect(runMutation).toHaveBeenNthCalledWith(
|
|
276
380
|
3,
|
|
277
381
|
components.agent.streams.abort,
|
|
278
|
-
{
|
|
382
|
+
{
|
|
383
|
+
streamId: "stream-1",
|
|
384
|
+
reason: "provider_disconnected: Provider dropped",
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("aborts the component stream when file materialization fails", async () => {
|
|
390
|
+
const materializationFailure = new Error("storage failed");
|
|
391
|
+
const runMutation = vi
|
|
392
|
+
.fn()
|
|
393
|
+
.mockResolvedValueOnce("stream-1")
|
|
394
|
+
.mockResolvedValueOnce(undefined);
|
|
395
|
+
const streamer = new DeltaStreamer<string>(
|
|
396
|
+
components.agent,
|
|
397
|
+
{ runMutation } as unknown as MutationCtx,
|
|
398
|
+
{
|
|
399
|
+
...defaultTestOptions,
|
|
400
|
+
onAsyncAbort: async () => {},
|
|
401
|
+
materialize: async () => {
|
|
402
|
+
throw materializationFailure;
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{ ...testMetadata, threadId },
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
await streamer.addParts(["A"]);
|
|
409
|
+
await expect(streamer.finish()).resolves.toBeUndefined();
|
|
410
|
+
expect(runMutation).toHaveBeenNthCalledWith(
|
|
411
|
+
2,
|
|
412
|
+
components.agent.streams.abort,
|
|
413
|
+
{ streamId: "stream-1", reason: "storage failed" },
|
|
279
414
|
);
|
|
280
415
|
});
|
|
281
416
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type UIMessageChunk,
|
|
9
9
|
} from "ai";
|
|
10
10
|
import { v } from "convex/values";
|
|
11
|
+
import { errorToString } from "../../errors.js";
|
|
11
12
|
import {
|
|
12
13
|
vMessageDoc,
|
|
13
14
|
vPaginationResult,
|
|
@@ -204,6 +205,12 @@ export class DeltaStreamer<T> {
|
|
|
204
205
|
throttleMs: number;
|
|
205
206
|
onAsyncAbort: (reason: string) => Promise<void>;
|
|
206
207
|
compress: ((parts: T[]) => T[]) | null;
|
|
208
|
+
materialize:
|
|
209
|
+
| ((parts: T[]) => Promise<{
|
|
210
|
+
parts: T[];
|
|
211
|
+
fileRefs: Array<{ url: string; fileId: string }>;
|
|
212
|
+
}>)
|
|
213
|
+
| null;
|
|
207
214
|
};
|
|
208
215
|
#nextParts: T[] = [];
|
|
209
216
|
#latestWrite: number = 0;
|
|
@@ -223,6 +230,10 @@ export class DeltaStreamer<T> {
|
|
|
223
230
|
onAsyncAbort: (reason: string) => Promise<void>;
|
|
224
231
|
abortSignal: AbortSignal | undefined;
|
|
225
232
|
compress: ((parts: T[]) => T[]) | null;
|
|
233
|
+
materialize?: (parts: T[]) => Promise<{
|
|
234
|
+
parts: T[];
|
|
235
|
+
fileRefs: Array<{ url: string; fileId: string }>;
|
|
236
|
+
}>;
|
|
226
237
|
},
|
|
227
238
|
public readonly metadata: {
|
|
228
239
|
threadId: string;
|
|
@@ -240,6 +251,7 @@ export class DeltaStreamer<T> {
|
|
|
240
251
|
throttleMs: config.throttleMs ?? DEFAULT_STREAMING_OPTIONS.throttleMs,
|
|
241
252
|
onAsyncAbort: config.onAsyncAbort,
|
|
242
253
|
compress: config.compress,
|
|
254
|
+
materialize: config.materialize ?? null,
|
|
243
255
|
};
|
|
244
256
|
this.#nextParts = [];
|
|
245
257
|
this.abortController = new AbortController();
|
|
@@ -289,7 +301,10 @@ export class DeltaStreamer<T> {
|
|
|
289
301
|
if (this.#finishedExternally) {
|
|
290
302
|
return;
|
|
291
303
|
}
|
|
292
|
-
await this.
|
|
304
|
+
const streamId = await this.getOrCreateStreamId({
|
|
305
|
+
ifAborted: "returnUndefined",
|
|
306
|
+
});
|
|
307
|
+
if (!streamId) return;
|
|
293
308
|
this.#nextParts.push(...parts);
|
|
294
309
|
if (
|
|
295
310
|
!this.#ongoingWrite &&
|
|
@@ -300,8 +315,16 @@ export class DeltaStreamer<T> {
|
|
|
300
315
|
}
|
|
301
316
|
|
|
302
317
|
public async consumeStream(stream: AsyncIterableStream<T>) {
|
|
303
|
-
|
|
304
|
-
await
|
|
318
|
+
try {
|
|
319
|
+
for await (const chunk of stream) {
|
|
320
|
+
await this.addParts([chunk]);
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
// A provider can throw while responding to an abort. Join the durable
|
|
324
|
+
// abort transition here, outside the active delta writer, before
|
|
325
|
+
// preserving the provider error for the caller.
|
|
326
|
+
await this.#abort(errorToString(error)).catch(() => {});
|
|
327
|
+
throw error;
|
|
305
328
|
}
|
|
306
329
|
// Skip finish if it will be handled externally (atomically with message save)
|
|
307
330
|
// or if the stream was aborted (e.g., due to a failed delta write).
|
|
@@ -327,29 +350,45 @@ export class DeltaStreamer<T> {
|
|
|
327
350
|
* Get the stream ID, waiting for it to be created if necessary.
|
|
328
351
|
* Useful for passing to addMessages for atomic finish.
|
|
329
352
|
*/
|
|
330
|
-
public async getOrCreateStreamId(): Promise<string
|
|
331
|
-
|
|
353
|
+
public async getOrCreateStreamId(): Promise<string>;
|
|
354
|
+
public async getOrCreateStreamId(options: {
|
|
355
|
+
ifAborted: "returnUndefined";
|
|
356
|
+
}): Promise<string | undefined>;
|
|
357
|
+
public async getOrCreateStreamId(options?: {
|
|
358
|
+
ifAborted?: "returnUndefined";
|
|
359
|
+
}): Promise<string | undefined> {
|
|
360
|
+
if (options?.ifAborted !== "returnUndefined") {
|
|
361
|
+
return this.getStreamId();
|
|
362
|
+
}
|
|
363
|
+
if (this.abortController.signal.aborted) {
|
|
364
|
+
await this.#abortPromise;
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
const streamId = await this.getStreamId();
|
|
368
|
+
if (this.abortController.signal.aborted) {
|
|
369
|
+
await this.#abortPromise;
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
return streamId;
|
|
332
373
|
}
|
|
333
374
|
|
|
334
375
|
async #sendDelta() {
|
|
335
376
|
if (this.abortController.signal.aborted) {
|
|
336
377
|
return;
|
|
337
378
|
}
|
|
338
|
-
const delta = this.#createDelta();
|
|
339
|
-
if (!delta) {
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
this.#latestWrite = Date.now();
|
|
343
379
|
let success: boolean;
|
|
344
380
|
try {
|
|
381
|
+
const delta = await this.#createDelta();
|
|
382
|
+
if (!delta) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.#latestWrite = Date.now();
|
|
345
386
|
success = await this.ctx.runMutation(
|
|
346
387
|
this.component.streams.addDelta,
|
|
347
388
|
delta,
|
|
348
389
|
);
|
|
349
390
|
} catch (e) {
|
|
350
|
-
await this.#abortDelta(
|
|
351
|
-
e instanceof Error ? e.message : "unknown error",
|
|
352
|
-
);
|
|
391
|
+
await this.#abortDelta(errorToString(e));
|
|
353
392
|
return;
|
|
354
393
|
}
|
|
355
394
|
if (!success) {
|
|
@@ -375,21 +414,36 @@ export class DeltaStreamer<T> {
|
|
|
375
414
|
}
|
|
376
415
|
}
|
|
377
416
|
|
|
378
|
-
#createDelta():
|
|
417
|
+
async #createDelta(): Promise<
|
|
418
|
+
| (StreamDelta & { fileRefs?: Array<{ url: string; fileId: string }> })
|
|
419
|
+
| undefined
|
|
420
|
+
> {
|
|
379
421
|
if (this.#nextParts.length === 0) {
|
|
380
422
|
return undefined;
|
|
381
423
|
}
|
|
382
424
|
const start = this.#cursor;
|
|
383
|
-
const
|
|
425
|
+
const pendingParts = this.#nextParts;
|
|
426
|
+
const end = start + pendingParts.length;
|
|
384
427
|
this.#cursor = end;
|
|
385
|
-
const parts = this.config.compress
|
|
386
|
-
? this.config.compress(this.#nextParts)
|
|
387
|
-
: this.#nextParts;
|
|
388
428
|
this.#nextParts = [];
|
|
429
|
+
const materialized = this.config.materialize
|
|
430
|
+
? await this.config.materialize(pendingParts)
|
|
431
|
+
: { parts: pendingParts, fileRefs: [] };
|
|
432
|
+
const parts = this.config.compress
|
|
433
|
+
? this.config.compress(materialized.parts)
|
|
434
|
+
: materialized.parts;
|
|
389
435
|
if (!this.streamId) {
|
|
390
436
|
throw new Error("Creating a delta before the stream is created");
|
|
391
437
|
}
|
|
392
|
-
return {
|
|
438
|
+
return {
|
|
439
|
+
streamId: this.streamId,
|
|
440
|
+
start,
|
|
441
|
+
end,
|
|
442
|
+
parts,
|
|
443
|
+
...(materialized.fileRefs.length > 0
|
|
444
|
+
? { fileRefs: materialized.fileRefs }
|
|
445
|
+
: {}),
|
|
446
|
+
};
|
|
393
447
|
}
|
|
394
448
|
|
|
395
449
|
public async finish() {
|
|
@@ -203,6 +203,8 @@ export type ContextOptions = {
|
|
|
203
203
|
/**
|
|
204
204
|
* How many recent messages to include. These are added after the search
|
|
205
205
|
* messages, and do not count against the search limit.
|
|
206
|
+
* If the limit splits an order, additional messages are included to complete
|
|
207
|
+
* that order. If it cannot be completed, the incomplete order is omitted.
|
|
206
208
|
* Default: 100
|
|
207
209
|
*/
|
|
208
210
|
recentMessages?: number;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { StepResult } from "ai";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { hasSuccessfulToolCall, willContinue } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
// Minimal StepResult builder — only the fields willContinue and
|
|
6
|
+
// hasSuccessfulToolCall actually read. Loosely typed on purpose so test
|
|
7
|
+
// fixtures can be terse; cast at the boundary.
|
|
8
|
+
type StepFixture = {
|
|
9
|
+
finishReason?: string;
|
|
10
|
+
content?: Array<{ type: string; toolName?: string }>;
|
|
11
|
+
toolCalls?: Array<{ toolCallId: string; toolName: string }>;
|
|
12
|
+
toolResults?: Array<{ toolCallId: string; toolName: string }>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function makeStep(partial: StepFixture): StepResult<any> {
|
|
16
|
+
return {
|
|
17
|
+
finishReason: "tool-calls",
|
|
18
|
+
content: [],
|
|
19
|
+
toolCalls: [],
|
|
20
|
+
toolResults: [],
|
|
21
|
+
...partial,
|
|
22
|
+
} as unknown as StepResult<any>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("hasSuccessfulToolCall", () => {
|
|
26
|
+
test("returns true when last step has a tool-result for the named tool", () => {
|
|
27
|
+
const step = makeStep({
|
|
28
|
+
content: [{ type: "tool-result", toolName: "search" }],
|
|
29
|
+
});
|
|
30
|
+
expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns false when only a tool-error is present for the named tool", () => {
|
|
34
|
+
const step = makeStep({
|
|
35
|
+
content: [{ type: "tool-error", toolName: "search" }],
|
|
36
|
+
});
|
|
37
|
+
expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns false when the matching tool name is missing", () => {
|
|
41
|
+
const step = makeStep({
|
|
42
|
+
content: [{ type: "tool-result", toolName: "other" }],
|
|
43
|
+
});
|
|
44
|
+
expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("only inspects the last step", () => {
|
|
48
|
+
const earlier = makeStep({
|
|
49
|
+
content: [{ type: "tool-result", toolName: "search" }],
|
|
50
|
+
});
|
|
51
|
+
const last = makeStep({
|
|
52
|
+
content: [{ type: "tool-error", toolName: "search" }],
|
|
53
|
+
});
|
|
54
|
+
expect(hasSuccessfulToolCall("search")({ steps: [earlier, last] })).toBe(
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns false when steps is empty", () => {
|
|
60
|
+
expect(hasSuccessfulToolCall("search")({ steps: [] })).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("willContinue", () => {
|
|
65
|
+
test("does not stop when a tool-error fills in for a missing tool-result", async () => {
|
|
66
|
+
// Two tool calls; one returns a result, the other errors.
|
|
67
|
+
const step = makeStep({
|
|
68
|
+
toolCalls: [
|
|
69
|
+
{ toolCallId: "1", toolName: "a" },
|
|
70
|
+
{ toolCallId: "2", toolName: "b" },
|
|
71
|
+
],
|
|
72
|
+
toolResults: [{ toolCallId: "1", toolName: "a" }],
|
|
73
|
+
content: [
|
|
74
|
+
{ type: "tool-result", toolName: "a" },
|
|
75
|
+
{ type: "tool-error", toolName: "b" },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
// No stopWhen → returns false (no further stop conditions). The point
|
|
79
|
+
// is the function progresses past the early `toolCalls > completed`
|
|
80
|
+
// bail; pre-fix it returned early because tool-error wasn't counted.
|
|
81
|
+
expect(await willContinue([step], undefined)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("stops when a tool call has neither a result nor an error yet", async () => {
|
|
85
|
+
const step = makeStep({
|
|
86
|
+
toolCalls: [{ toolCallId: "1", toolName: "a" }],
|
|
87
|
+
toolResults: [],
|
|
88
|
+
content: [],
|
|
89
|
+
});
|
|
90
|
+
expect(await willContinue([step], undefined)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("stops when finishReason is not tool-calls", async () => {
|
|
94
|
+
const step = makeStep({ finishReason: "stop" });
|
|
95
|
+
expect(await willContinue([step], undefined)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import type { Context } from "@ai-sdk/provider-utils";
|
|
2
2
|
import type { StepResult, StopCondition, ToolSet } from "ai";
|
|
3
3
|
|
|
4
|
+
export { errorToString } from "../../errors.js";
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
|
-
* A stop condition that only matches tool calls which
|
|
6
|
-
* successfully
|
|
7
|
+
* A stop condition that only matches tool calls of the given name which
|
|
8
|
+
* completed successfully — i.e. produced a `tool-result` content part.
|
|
9
|
+
* Failed tool calls (which surface as `tool-error` parts under AI SDK v6)
|
|
10
|
+
* do not match.
|
|
7
11
|
*
|
|
8
|
-
* Use this instead of the AI SDK's `hasToolCall` when you want the
|
|
9
|
-
*
|
|
12
|
+
* Use this instead of the AI SDK's `hasToolCall` when you want the agent
|
|
13
|
+
* to retry on argument-validation or runtime tool failures rather than
|
|
14
|
+
* stopping. Evaluated only against the last step (consistent with how
|
|
15
|
+
* `stopWhen` is applied after each step).
|
|
10
16
|
*/
|
|
11
17
|
export function hasSuccessfulToolCall(toolName: string): StopCondition<any> {
|
|
12
18
|
return ({ steps }) =>
|
|
13
|
-
steps[steps.length - 1]?.
|
|
14
|
-
(
|
|
19
|
+
steps[steps.length - 1]?.content?.some(
|
|
20
|
+
(p) => p.type === "tool-result" && p.toolName === toolName,
|
|
15
21
|
) ?? false;
|
|
16
22
|
}
|
|
17
23
|
|
|
@@ -31,12 +37,13 @@ export async function willContinue<
|
|
|
31
37
|
if (step.finishReason !== "tool-calls") return false;
|
|
32
38
|
// Count both successful results and errors as completed outputs.
|
|
33
39
|
// Failed tool calls are represented as tool-error content parts, so only
|
|
34
|
-
// checking toolResults misses them.
|
|
40
|
+
// checking toolResults misses them. The fallback to step.toolResults.length
|
|
41
|
+
// is for callers whose steps lack content (mocks); the optional chain is
|
|
42
|
+
// defensive rather than load-bearing.
|
|
35
43
|
const completedOutputs =
|
|
36
44
|
step.content?.filter(
|
|
37
45
|
(p) => p.type === "tool-result" || p.type === "tool-error",
|
|
38
46
|
).length ?? step.toolResults.length;
|
|
39
|
-
// we don't have a tool result, so we'll wait for more
|
|
40
47
|
if (step.toolCalls.length > completedOutputs) return false;
|
|
41
48
|
if (Array.isArray(stopWhen)) {
|
|
42
49
|
return (await Promise.all(stopWhen.map(async (s) => s({ steps })))).every(
|
|
@@ -45,10 +52,3 @@ export async function willContinue<
|
|
|
45
52
|
}
|
|
46
53
|
return !!stopWhen && !(await stopWhen({ steps }));
|
|
47
54
|
}
|
|
48
|
-
|
|
49
|
-
export function errorToString(error: unknown): string {
|
|
50
|
-
if (error instanceof Error) {
|
|
51
|
-
return error.message;
|
|
52
|
-
}
|
|
53
|
-
return String(error);
|
|
54
|
-
}
|