@convex-dev/agent 0.7.1 → 0.7.3
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 +10 -8
- package/dist/component/_generated/component.d.ts.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 +74 -14
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +726 -622
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/streams.d.ts +6 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +14 -15
- package/dist/component/streams.js.map +1 -1
- package/dist/component/threads.d.ts.map +1 -1
- package/dist/component/threads.js +1 -0
- package/dist/component/threads.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/validators.d.ts +1952 -1952
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- 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 +79 -22
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +326 -305
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +140 -25
- 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/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/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +10 -0
- package/src/component/messages.test.ts +404 -0
- package/src/component/messages.ts +94 -12
- package/src/component/streams.test.ts +54 -1
- package/src/component/streams.ts +20 -17
- package/src/component/threads.test.ts +39 -0
- package/src/component/threads.ts +1 -0
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/vercel/client/deltaFlush.test.ts +106 -0
- 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 +577 -18
- package/src/vercel/client/streamText.ts +84 -32
- package/src/vercel/client/streaming.integration.test.ts +40 -3
- package/src/vercel/client/streaming.test.ts +101 -7
- package/src/vercel/client/streaming.throttle.test.ts +144 -0
- package/src/vercel/client/streaming.ts +161 -25
- 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/index.ts +2 -0
|
@@ -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,
|
|
@@ -132,6 +133,11 @@ export async function listStreams(
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
export type StreamingOptions = {
|
|
136
|
+
/**
|
|
137
|
+
* Whether source parts emitted by the model are included in the persisted
|
|
138
|
+
* delta stream. Defaults to false.
|
|
139
|
+
*/
|
|
140
|
+
sendSources?: boolean;
|
|
135
141
|
/**
|
|
136
142
|
* The minimum granularity of deltas to save.
|
|
137
143
|
* Note: this is not a guarantee that every delta will be exactly one line.
|
|
@@ -155,6 +161,7 @@ export type StreamingOptions = {
|
|
|
155
161
|
returnImmediately?: boolean;
|
|
156
162
|
};
|
|
157
163
|
export const DEFAULT_STREAMING_OPTIONS = {
|
|
164
|
+
sendSources: false,
|
|
158
165
|
// This chunks by sentences / clauses. Punctuation followed by whitespace.
|
|
159
166
|
chunking: /[\p{P}\s]/u,
|
|
160
167
|
throttleMs: 250,
|
|
@@ -214,12 +221,22 @@ export class DeltaStreamer<T> {
|
|
|
214
221
|
#nextParts: T[] = [];
|
|
215
222
|
#latestWrite: number = 0;
|
|
216
223
|
#ongoingWrite: Promise<void> | undefined;
|
|
224
|
+
#flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
217
225
|
#abortPromise: Promise<void> | undefined;
|
|
218
226
|
#cursor: number = 0;
|
|
219
227
|
public abortController: AbortController;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
/**
|
|
229
|
+
* When true, external code finishes the stream row (atomically with the
|
|
230
|
+
* message save, issue #181) and `consumeStream` must not. Decided once, at
|
|
231
|
+
* construction.
|
|
232
|
+
*/
|
|
233
|
+
#finishHandledExternally: boolean;
|
|
234
|
+
/**
|
|
235
|
+
* Set only where the row is finished before the source is drained. An
|
|
236
|
+
* optimization, not a race guard: `addDelta` already returns false for a
|
|
237
|
+
* non-streaming row.
|
|
238
|
+
*/
|
|
239
|
+
#stoppedAccepting: boolean = false;
|
|
223
240
|
|
|
224
241
|
constructor(
|
|
225
242
|
public readonly component: AgentComponent,
|
|
@@ -229,6 +246,8 @@ export class DeltaStreamer<T> {
|
|
|
229
246
|
onAsyncAbort: (reason: string) => Promise<void>;
|
|
230
247
|
abortSignal: AbortSignal | undefined;
|
|
231
248
|
compress: ((parts: T[]) => T[]) | null;
|
|
249
|
+
/** Defaults to false, meaning `consumeStream` finishes the stream. */
|
|
250
|
+
finishHandledExternally?: boolean;
|
|
232
251
|
materialize?: (parts: T[]) => Promise<{
|
|
233
252
|
parts: T[];
|
|
234
253
|
fileRefs: Array<{ url: string; fileId: string }>;
|
|
@@ -252,6 +271,7 @@ export class DeltaStreamer<T> {
|
|
|
252
271
|
compress: config.compress,
|
|
253
272
|
materialize: config.materialize ?? null,
|
|
254
273
|
};
|
|
274
|
+
this.#finishHandledExternally = config.finishHandledExternally ?? false;
|
|
255
275
|
this.#nextParts = [];
|
|
256
276
|
this.abortController = new AbortController();
|
|
257
277
|
if (config.abortSignal) {
|
|
@@ -293,20 +313,64 @@ export class DeltaStreamer<T> {
|
|
|
293
313
|
if (this.abortController.signal.aborted) {
|
|
294
314
|
return;
|
|
295
315
|
}
|
|
296
|
-
|
|
297
|
-
// save in streamText's onStepFinish for the returnImmediately path),
|
|
298
|
-
// the stream record is already "finished" in the DB. Late deltas
|
|
299
|
-
// would be silently dropped by streams.addDelta — skip the work.
|
|
300
|
-
if (this.#finishedExternally) {
|
|
316
|
+
if (this.#stoppedAccepting) {
|
|
301
317
|
return;
|
|
302
318
|
}
|
|
303
|
-
|
|
319
|
+
// Buffer before awaiting: a part parked in stream creation would be
|
|
320
|
+
// invisible to #flushPendingParts.
|
|
304
321
|
this.#nextParts.push(...parts);
|
|
322
|
+
const streamId = await this.getOrCreateStreamId({
|
|
323
|
+
ifAborted: "returnUndefined",
|
|
324
|
+
});
|
|
325
|
+
if (!streamId) return;
|
|
326
|
+
if (this.#stoppedAccepting || this.abortController.signal.aborted) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
305
329
|
if (
|
|
306
330
|
!this.#ongoingWrite &&
|
|
307
331
|
Date.now() - this.#latestWrite >= this.config.throttleMs
|
|
308
332
|
) {
|
|
309
333
|
this.#ongoingWrite = this.#sendDelta();
|
|
334
|
+
} else {
|
|
335
|
+
this.#scheduleFlush();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The throttle is only reconsidered when the next part arrives, so a pause in
|
|
340
|
+
// the stream would otherwise hold whatever is buffered until it resumes.
|
|
341
|
+
#scheduleFlush() {
|
|
342
|
+
if (this.#flushTimer) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const wait = Math.max(
|
|
346
|
+
0,
|
|
347
|
+
this.config.throttleMs - (Date.now() - this.#latestWrite),
|
|
348
|
+
);
|
|
349
|
+
this.#flushTimer = setTimeout(() => {
|
|
350
|
+
this.#flushTimer = undefined;
|
|
351
|
+
if (
|
|
352
|
+
this.#ongoingWrite ||
|
|
353
|
+
this.#nextParts.length === 0 ||
|
|
354
|
+
this.#stoppedAccepting ||
|
|
355
|
+
this.abortController.signal.aborted
|
|
356
|
+
) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// A write can start and land while this wake is armed, which moves the
|
|
360
|
+
// deadline out from under it. Re-arm against the current one rather
|
|
361
|
+
// than publishing early.
|
|
362
|
+
if (Date.now() - this.#latestWrite < this.config.throttleMs) {
|
|
363
|
+
this.#scheduleFlush();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
this.#ongoingWrite = this.#sendDelta();
|
|
367
|
+
}, wait);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#cancelScheduledFlush() {
|
|
371
|
+
if (this.#flushTimer) {
|
|
372
|
+
clearTimeout(this.#flushTimer);
|
|
373
|
+
this.#flushTimer = undefined;
|
|
310
374
|
}
|
|
311
375
|
}
|
|
312
376
|
|
|
@@ -319,45 +383,110 @@ export class DeltaStreamer<T> {
|
|
|
319
383
|
// A provider can throw while responding to an abort. Join the durable
|
|
320
384
|
// abort transition here, outside the active delta writer, before
|
|
321
385
|
// preserving the provider error for the caller.
|
|
322
|
-
await this.#abort(
|
|
323
|
-
error instanceof Error ? error.message : "stream consumption failed",
|
|
324
|
-
).catch(() => {});
|
|
386
|
+
await this.#abort(errorToString(error)).catch(() => {});
|
|
325
387
|
throw error;
|
|
326
388
|
}
|
|
327
|
-
// Skip finish if it will be handled externally (atomically with message save)
|
|
328
|
-
// or if the stream was aborted (e.g., due to a failed delta write).
|
|
329
389
|
// Abort cleanup owns the terminal component transition, so consumeStream
|
|
330
390
|
// must wait for it instead of also trying to finish the stream.
|
|
331
391
|
if (this.abortController.signal.aborted) {
|
|
332
392
|
await this.#waitForAbortCleanup();
|
|
333
|
-
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// EOF is the only point where every part has actually been handed over:
|
|
396
|
+
// parts sit in the AI SDK's transform and tee pipeline until the iterator
|
|
397
|
+
// yields them, so no earlier callback can observe them.
|
|
398
|
+
try {
|
|
399
|
+
await this.#flushPendingParts();
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (this.abortController.signal.aborted) {
|
|
402
|
+
await this.#waitForAbortCleanup();
|
|
403
|
+
}
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
if (this.abortController.signal.aborted) {
|
|
407
|
+
await this.#waitForAbortCleanup();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (!this.#finishHandledExternally) {
|
|
334
411
|
await this.finish();
|
|
335
412
|
}
|
|
336
413
|
}
|
|
337
414
|
|
|
338
415
|
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* handled elsewhere in the same mutation as message saving.
|
|
416
|
+
* Drain everything currently buffered or in flight, so that after this
|
|
417
|
+
* resolves no delta write is outstanding and #nextParts is empty.
|
|
342
418
|
*/
|
|
343
|
-
|
|
344
|
-
this
|
|
419
|
+
async #flushPendingParts(): Promise<void> {
|
|
420
|
+
while (!this.abortController.signal.aborted) {
|
|
421
|
+
const inFlight = this.#ongoingWrite;
|
|
422
|
+
await inFlight;
|
|
423
|
+
// #sendDelta reassigns #ongoingWrite from its own tail, so a write can
|
|
424
|
+
// still be live even though the buffer it drained is now empty.
|
|
425
|
+
if (this.#ongoingWrite !== inFlight) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (this.#nextParts.length === 0) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
this.#ongoingWrite = this.#sendDelta();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* For the `returnImmediately` path, where nothing awaits consumption and the
|
|
437
|
+
* save has to happen inline (issue #265).
|
|
438
|
+
*
|
|
439
|
+
* Inherent window: parts still inside the AI SDK pipeline at this instant
|
|
440
|
+
* never reach addParts, so they are never persisted as deltas (the
|
|
441
|
+
* stream-level `finish` chunk among them). The message saved alongside this
|
|
442
|
+
* transition is complete, and is authoritative once the stream is finished.
|
|
443
|
+
*
|
|
444
|
+
* Narrower race left open: a part admitted after the loop's last emptiness
|
|
445
|
+
* check is buffered but not drained here, and the write it triggers at EOF
|
|
446
|
+
* lands on a row the caller has since finished. `#sendDelta` tolerates that
|
|
447
|
+
* refusal rather than aborting. Closing it means refusing admission for the
|
|
448
|
+
* duration of the drain, which drops more of the tail than it saves.
|
|
449
|
+
*/
|
|
450
|
+
public async flushAndStopAccepting(): Promise<void> {
|
|
451
|
+
this.#cancelScheduledFlush();
|
|
452
|
+
await this.#flushPendingParts();
|
|
453
|
+
this.#stoppedAccepting = true;
|
|
345
454
|
}
|
|
346
455
|
|
|
347
456
|
/**
|
|
348
457
|
* Get the stream ID, waiting for it to be created if necessary.
|
|
349
458
|
* Useful for passing to addMessages for atomic finish.
|
|
350
459
|
*/
|
|
351
|
-
public async getOrCreateStreamId(): Promise<string
|
|
352
|
-
|
|
460
|
+
public async getOrCreateStreamId(): Promise<string>;
|
|
461
|
+
public async getOrCreateStreamId(options: {
|
|
462
|
+
ifAborted: "returnUndefined";
|
|
463
|
+
}): Promise<string | undefined>;
|
|
464
|
+
public async getOrCreateStreamId(options?: {
|
|
465
|
+
ifAborted?: "returnUndefined";
|
|
466
|
+
}): Promise<string | undefined> {
|
|
467
|
+
if (options?.ifAborted !== "returnUndefined") {
|
|
468
|
+
return this.getStreamId();
|
|
469
|
+
}
|
|
470
|
+
if (this.abortController.signal.aborted) {
|
|
471
|
+
await this.#abortPromise;
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
const streamId = await this.getStreamId();
|
|
475
|
+
if (this.abortController.signal.aborted) {
|
|
476
|
+
await this.#abortPromise;
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
return streamId;
|
|
353
480
|
}
|
|
354
481
|
|
|
355
482
|
async #sendDelta() {
|
|
483
|
+
this.#cancelScheduledFlush();
|
|
356
484
|
if (this.abortController.signal.aborted) {
|
|
357
485
|
return;
|
|
358
486
|
}
|
|
359
487
|
let success: boolean;
|
|
360
488
|
try {
|
|
489
|
+
await this.getStreamId();
|
|
361
490
|
const delta = await this.#createDelta();
|
|
362
491
|
if (!delta) {
|
|
363
492
|
return;
|
|
@@ -368,15 +497,15 @@ export class DeltaStreamer<T> {
|
|
|
368
497
|
delta,
|
|
369
498
|
);
|
|
370
499
|
} catch (e) {
|
|
371
|
-
await this.#abortDelta(e
|
|
500
|
+
await this.#abortDelta(errorToString(e));
|
|
372
501
|
return;
|
|
373
502
|
}
|
|
374
503
|
if (!success) {
|
|
375
|
-
//
|
|
504
|
+
// A #sendDelta racing the inline save on the returnImmediately path
|
|
376
505
|
// will get `success === false` because the stream row is already
|
|
377
506
|
// "finished". That's a benign late-write miss, not a failure —
|
|
378
507
|
// don't convert it into an abort.
|
|
379
|
-
if (this.#
|
|
508
|
+
if (this.#stoppedAccepting) {
|
|
380
509
|
return;
|
|
381
510
|
}
|
|
382
511
|
await this.#abortDelta("async abort");
|
|
@@ -391,6 +520,11 @@ export class DeltaStreamer<T> {
|
|
|
391
520
|
this.#ongoingWrite = this.#sendDelta();
|
|
392
521
|
} else {
|
|
393
522
|
this.#ongoingWrite = undefined;
|
|
523
|
+
// Whatever is still buffered has just lost its owner: this write is
|
|
524
|
+
// over and no arrival is guaranteed to follow. Hand it to the timer.
|
|
525
|
+
if (this.#nextParts.length > 0) {
|
|
526
|
+
this.#scheduleFlush();
|
|
527
|
+
}
|
|
394
528
|
}
|
|
395
529
|
}
|
|
396
530
|
|
|
@@ -427,6 +561,7 @@ export class DeltaStreamer<T> {
|
|
|
427
561
|
}
|
|
428
562
|
|
|
429
563
|
public async finish() {
|
|
564
|
+
this.#cancelScheduledFlush();
|
|
430
565
|
if (!this.streamId) {
|
|
431
566
|
return;
|
|
432
567
|
}
|
|
@@ -471,6 +606,7 @@ export class DeltaStreamer<T> {
|
|
|
471
606
|
|
|
472
607
|
#abort(reason: string, waitForOngoingWrite = true): Promise<void> {
|
|
473
608
|
if (!this.#abortPromise) {
|
|
609
|
+
this.#cancelScheduledFlush();
|
|
474
610
|
this.abortController.abort();
|
|
475
611
|
this.#abortPromise = this.#abortCreatedStream(
|
|
476
612
|
reason,
|
|
@@ -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
|
-
}
|
package/src/vercel/index.ts
CHANGED
|
@@ -818,6 +818,7 @@ export class Agent<
|
|
|
818
818
|
metadata: args.metadata ? [args.metadata] : undefined,
|
|
819
819
|
skipEmbeddings: args.skipEmbeddings,
|
|
820
820
|
promptMessageId: args.promptMessageId,
|
|
821
|
+
order: args.order,
|
|
821
822
|
pendingMessageId: args.pendingMessageId,
|
|
822
823
|
});
|
|
823
824
|
const message = messages.at(-1)!;
|
|
@@ -1765,6 +1766,7 @@ export class Agent<
|
|
|
1765
1766
|
threadId: v.string(),
|
|
1766
1767
|
userId: v.optional(v.string()),
|
|
1767
1768
|
promptMessageId: v.optional(v.string()),
|
|
1769
|
+
order: v.optional(v.union(v.number(), v.literal("next"))),
|
|
1768
1770
|
messages: v.array(vMessageWithMetadata),
|
|
1769
1771
|
failPendingSteps: v.optional(v.boolean()),
|
|
1770
1772
|
embeddings: v.optional(vMessageEmbeddings),
|