convex-durable-agents 0.2.3 → 0.2.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.
Files changed (70) hide show
  1. package/README.md +81 -7
  2. package/dist/client/api.d.ts.map +1 -1
  3. package/dist/client/api.js +23 -4
  4. package/dist/client/api.js.map +1 -1
  5. package/dist/client/handler.d.ts +20 -0
  6. package/dist/client/handler.d.ts.map +1 -1
  7. package/dist/client/handler.js +239 -117
  8. package/dist/client/handler.js.map +1 -1
  9. package/dist/client/streamer.js +1 -1
  10. package/dist/client/streamer.js.map +1 -1
  11. package/dist/client/tools.d.ts +17 -0
  12. package/dist/client/tools.d.ts.map +1 -1
  13. package/dist/client/tools.js +16 -0
  14. package/dist/client/tools.js.map +1 -1
  15. package/dist/client/types.d.ts +75 -1
  16. package/dist/client/types.d.ts.map +1 -1
  17. package/dist/client/types.js +11 -0
  18. package/dist/client/types.js.map +1 -1
  19. package/dist/component/_generated/component.d.ts +89 -0
  20. package/dist/component/_generated/component.d.ts.map +1 -1
  21. package/dist/component/agent.d.ts.map +1 -1
  22. package/dist/component/agent.js +21 -2
  23. package/dist/component/agent.js.map +1 -1
  24. package/dist/component/schema.d.ts +70 -2
  25. package/dist/component/schema.d.ts.map +1 -1
  26. package/dist/component/schema.js +21 -0
  27. package/dist/component/schema.js.map +1 -1
  28. package/dist/component/streams.js +2 -2
  29. package/dist/component/streams.js.map +1 -1
  30. package/dist/component/threads.d.ts +92 -2
  31. package/dist/component/threads.d.ts.map +1 -1
  32. package/dist/component/threads.js +83 -2
  33. package/dist/component/threads.js.map +1 -1
  34. package/dist/component/tool_calls.d.ts +54 -3
  35. package/dist/component/tool_calls.d.ts.map +1 -1
  36. package/dist/component/tool_calls.js +345 -35
  37. package/dist/component/tool_calls.js.map +1 -1
  38. package/dist/utils/logger.d.ts.map +1 -0
  39. package/dist/utils/logger.js.map +1 -0
  40. package/dist/utils/retry.d.ts +69 -0
  41. package/dist/utils/retry.d.ts.map +1 -0
  42. package/dist/utils/retry.js +404 -0
  43. package/dist/utils/retry.js.map +1 -0
  44. package/dist/utils/streaming.d.ts +4 -0
  45. package/dist/utils/streaming.d.ts.map +1 -0
  46. package/dist/utils/streaming.js +4 -0
  47. package/dist/utils/streaming.js.map +1 -0
  48. package/package.json +1 -1
  49. package/src/client/api.ts +24 -4
  50. package/src/client/handler.ts +308 -133
  51. package/src/client/streamer.ts +1 -1
  52. package/src/client/tools.ts +43 -1
  53. package/src/client/types.ts +60 -0
  54. package/src/component/_generated/component.ts +103 -0
  55. package/src/component/agent.ts +24 -2
  56. package/src/component/schema.ts +22 -0
  57. package/src/component/streams.ts +2 -2
  58. package/src/component/threads.ts +92 -3
  59. package/src/component/tool_calls.ts +421 -44
  60. package/src/utils/retry.ts +528 -0
  61. package/src/{streaming.ts → utils/streaming.ts} +2 -3
  62. package/dist/logger.d.ts.map +0 -1
  63. package/dist/logger.js.map +0 -1
  64. package/dist/streaming.d.ts +0 -3
  65. package/dist/streaming.d.ts.map +0 -1
  66. package/dist/streaming.js +0 -4
  67. package/dist/streaming.js.map +0 -1
  68. /package/dist/{logger.d.ts → utils/logger.d.ts} +0 -0
  69. /package/dist/{logger.js → utils/logger.js} +0 -0
  70. /package/src/{logger.ts → utils/logger.ts} +0 -0
@@ -12,8 +12,19 @@ import { type FunctionReference, type GenericActionCtx, internalActionGeneric }
12
12
  import { v } from "convex/values";
13
13
  import type { ComponentApi } from "../component/_generated/component.js";
14
14
  import type { Id } from "../component/_generated/dataModel.js";
15
- import { Logger } from "../logger.js";
16
- import { STREAM_HEARTBEAT_INTERVAL_MS } from "../streaming.js";
15
+ import { Logger } from "../utils/logger.js";
16
+ import {
17
+ clampDelayMs,
18
+ classifyRetryErrorDefault,
19
+ computeBackoffDelayMs,
20
+ DEFAULT_RETRY_MAX_ATTEMPTS,
21
+ extractRetryAfterDelayMs,
22
+ normalizeErrorMessage,
23
+ type RetryDecision,
24
+ type RetryErrorKind,
25
+ type RetryOptions,
26
+ } from "../utils/retry.js";
27
+ import { STREAM_HEARTBEAT_INTERVAL_MS } from "../utils/streaming.js";
17
28
  import { serializeForConvex } from "./helpers.js";
18
29
  import { Streamer } from "./streamer.js";
19
30
  import { buildToolDefinitions, type ToolDefinition } from "./tools.js";
@@ -34,6 +45,15 @@ const DEFAULT_STREAMING_OPTIONS: StreamingOptions = {
34
45
  returnImmediately: false,
35
46
  };
36
47
 
48
+ export type {
49
+ RetryContext,
50
+ RetryDecision,
51
+ RetryErrorClassification,
52
+ RetryErrorKind,
53
+ RetryErrorSignal,
54
+ RetryOptions,
55
+ } from "../utils/retry.js";
56
+
37
57
  // ============================================================================
38
58
  // Stream Handler Action
39
59
  // ============================================================================
@@ -61,6 +81,11 @@ export type ErrorHandlerArgs = {
61
81
  threadId: string;
62
82
  streamId: string;
63
83
  error: string;
84
+ kind?: RetryErrorKind | "tool_execution";
85
+ retryable?: boolean;
86
+ attempt?: number;
87
+ maxAttempts?: number;
88
+ requiresExplicitHandling?: boolean;
64
89
  };
65
90
 
66
91
  export type ErrorHandlerCallback = (ctx: ActionCtx, args: ErrorHandlerArgs) => void | Promise<void>;
@@ -77,6 +102,22 @@ export type StreamHandlerArgs = Omit<Parameters<typeof streamText>[0], "tools" |
77
102
  onTurnComplete?: TurnCompleteCallback;
78
103
  /** Optional: Callback when an error occurs during the stream handler invocation */
79
104
  onError?: ErrorHandlerCallback;
105
+ /** Optional: Configure automatic retry handling for stream failures */
106
+ retry?: false | RetryOptions;
107
+ /** Optional: Callback invoked when a retry is scheduled */
108
+ onRetry?: (
109
+ ctx: ActionCtx,
110
+ args: {
111
+ scope: "stream";
112
+ threadId: string;
113
+ streamId: string;
114
+ attempt: number;
115
+ maxAttempts: number;
116
+ delayMs: number;
117
+ kind?: RetryErrorKind;
118
+ error: string;
119
+ },
120
+ ) => void | Promise<void>;
80
121
  /** Optional: Function to enqueue actions via workpool (used for both stream handler and tools unless overridden) */
81
122
  workpoolEnqueueAction?: FunctionReference<"mutation", "internal">;
82
123
  /** Optional: Override workpool for tool execution only */
@@ -123,6 +164,7 @@ export function streamHandlerAction(
123
164
  args: tc.args,
124
165
  handler: toolDef.handler,
125
166
  saveDelta: tc.saveDelta,
167
+ retry: toolDef.retry,
126
168
  });
127
169
  } else {
128
170
  // Async tool - schedule callback that does NOT return the result
@@ -158,6 +200,8 @@ export function streamHandlerAction(
158
200
  onMessageComplete: usageHandlerCallback,
159
201
  onTurnComplete: turnCompleteHandlerCallback,
160
202
  onError: errorHandlerCallback,
203
+ retry,
204
+ onRetry: onRetryCallback,
161
205
  ...streamTextArgs
162
206
  } = resolvedArgs;
163
207
 
@@ -183,13 +227,14 @@ export function streamHandlerAction(
183
227
  logger.debug(`Stream lock acquired (seq=${stream.seq})`);
184
228
  streamer.startHeartbeat();
185
229
  let finalStatus: "awaiting_tool_results" | "completed" | "failed" | undefined;
230
+ let classifiedErrorKind: RetryErrorKind | "tool_execution" | undefined;
231
+ let classifiedRetryable: boolean | undefined;
232
+ let classifiedRequiresExplicitHandling: boolean | undefined;
233
+ let classifiedAttempt: number | undefined;
234
+ let classifiedMaxAttempts: number | undefined;
235
+ const retryEnabled = retry !== false && (retry?.enabled ?? true);
236
+ const maxRetryAttempts = retryEnabled ? Math.max(1, retry?.maxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS) : 1;
186
237
  try {
187
- logger.debug("Applying tool outcomes and fetching messages...");
188
- const messages: MessageDoc[] = await ctx.runMutation(component.messages.applyToolOutcomes, {
189
- threadId: args.threadId,
190
- });
191
- logger.debug(`Fetched ${messages.length} messages from thread`);
192
-
193
238
  // Set up delta streamer if enabled
194
239
  if (saveStreamDeltas) {
195
240
  logger.debug("Enabling delta streaming");
@@ -212,158 +257,283 @@ export function streamHandlerAction(
212
257
  });
213
258
  }
214
259
 
260
+ const thread = await ctx.runQuery(component.threads.get, {
261
+ threadId: args.threadId as Id<"threads">,
262
+ });
263
+ if (!thread) {
264
+ throw new Error(`Thread ${args.threadId} not found`);
265
+ }
266
+
267
+ const retryState = thread.retryState?.scope === "stream" ? thread.retryState : undefined;
268
+ const retryAttempt = retryState ? retryState.attempt + 1 : 1;
269
+ logger.debug(`Stream attempt ${retryAttempt}/${maxRetryAttempts}`);
270
+ logger.debug("Applying tool outcomes and fetching messages...");
271
+ const messages: MessageDoc[] = await ctx.runMutation(component.messages.applyToolOutcomes, {
272
+ threadId: args.threadId,
273
+ });
274
+ logger.debug(`Fetched ${messages.length} messages from thread`);
275
+
215
276
  const uiMessages = messages.map((m) => messageDocToUIMessage(m));
216
277
  logger.debug(`Converted ${uiMessages.length} UI messages, transforming to model messages...`);
217
278
  const modelMessages = transformMessages(await convertToModelMessages(uiMessages, { tools: handlerlessTools }));
218
279
  logger.debug(`Model messages ready (${modelMessages.length} messages), starting streamText...`);
219
- const result = streamText({
220
- ...streamTextArgs,
221
- prompt: undefined,
222
- messages: modelMessages,
223
- tools: handlerlessTools,
224
- });
225
280
 
226
- let finishReason: string | undefined;
227
- let responseMessage: UIMessage | undefined;
281
+ let toolCallCount = 0;
282
+ let streamPartCount = 0;
283
+ try {
284
+ const result = streamText({
285
+ ...streamTextArgs,
286
+ prompt: undefined,
287
+ messages: modelMessages,
288
+ tools: handlerlessTools,
289
+ });
228
290
 
229
- const uiMessageStream = result.toUIMessageStream({
230
- generateMessageId: generateId,
231
- originalMessages: uiMessages,
232
- onFinish: ({ responseMessage: finalResponseMessage }) => {
233
- responseMessage = finalResponseMessage;
234
- },
235
- });
291
+ let finishReason: string | undefined;
292
+ let responseMessage: UIMessage | undefined;
236
293
 
237
- let msgId: string | undefined;
238
- if (messages.length > 0) {
239
- msgId = messages[messages.length - 1]!.id;
240
- logger.debug(`Setting initial message ID from last message: ${msgId}`);
241
- await streamer.setMessageId(msgId, true);
242
- }
243
- let toolCallCount = 0;
244
- logger.debug("Processing UI message stream parts...");
245
- for await (const part of uiMessageStream) {
246
- if (part.type === "start") {
247
- msgId = part.messageId;
248
- logger.debug(`Stream part: start (messageId=${msgId})`);
249
- await streamer.setMessageId(
250
- msgId,
251
- messages?.some((m) => m.id === msgId),
252
- );
253
- }
254
- await streamer.process(part);
294
+ const uiMessageStream = result.toUIMessageStream({
295
+ generateMessageId: generateId,
296
+ originalMessages: uiMessages,
297
+ onFinish: ({ responseMessage: finalResponseMessage }) => {
298
+ responseMessage = finalResponseMessage;
299
+ },
300
+ });
255
301
 
256
- switch (part.type) {
257
- case "tool-input-available":
258
- toolCallCount++;
259
- logger.debug(
260
- `Stream part: tool-input-available (tool=${part.toolName}, callId=${part.toolCallId}, count=${toolCallCount})`,
261
- );
262
- await scheduleToolCall(
263
- ctx,
264
- {
265
- toolCallId: part.toolCallId,
266
- toolName: part.toolName,
267
- args: part.input,
268
- msgId: msgId,
269
- threadId: args.threadId,
270
- saveDelta: !!saveStreamDeltas,
271
- },
272
- toolDefinitions,
273
- logger,
274
- );
275
- break;
276
- case "finish":
277
- finishReason = part.finishReason;
278
- logger.debug(`Stream part: finish (reason=${finishReason})`);
279
- break;
280
- case "error":
281
- logger.error("Stream error:", part.errorText);
282
- throw new Error(`Stream error: ${part.errorText}`);
283
- default:
284
- // Ignore other part types
285
- break;
302
+ let msgId: string | undefined;
303
+ if (messages.length > 0) {
304
+ msgId = messages[messages.length - 1]!.id;
305
+ logger.debug(`Setting initial message ID from last message: ${msgId}`);
306
+ await streamer.setMessageId(msgId, true);
286
307
  }
287
- }
288
- logger.debug(`Stream iteration complete (toolCallCount=${toolCallCount}, finishReason=${finishReason})`);
289
-
290
- if (!responseMessage) {
291
- throw new Error("No response message");
292
- }
293
- logger.debug(`Response message received (role=${responseMessage.role}, parts=${responseMessage.parts.length})`);
308
+ logger.debug("Processing UI message stream parts...");
309
+ for await (const part of uiMessageStream) {
310
+ // Track whether meaningful stream output was emitted; start/finish/error
311
+ // control parts alone should not block a retry.
312
+ if (part.type !== "start" && part.type !== "finish" && part.type !== "error") {
313
+ streamPartCount++;
314
+ }
315
+ if (part.type === "start") {
316
+ msgId = part.messageId;
317
+ logger.debug(`Stream part: start (messageId=${msgId})`);
318
+ await streamer.setMessageId(
319
+ msgId,
320
+ messages?.some((m) => m.id === msgId),
321
+ );
322
+ }
323
+ await streamer.process(part);
294
324
 
295
- const providerMetadata = await getStreamTextProviderMetadata(result);
296
- const usage = await getStreamTextUsage(result, providerMetadata);
297
- logger.debug(`Usage info: ${usage ? JSON.stringify(usage) : "none"}`);
298
- if (usage && usageHandlerCallback) {
299
- logger.debug("Invoking onMessageComplete callback...");
300
- try {
301
- await usageHandlerCallback(ctx as ActionCtx, {
302
- threadId: args.threadId,
303
- streamId: args.streamId,
304
- message: responseMessage,
305
- usage,
306
- providerMetadata: serializeForConvex(providerMetadata),
307
- });
308
- logger.debug("onMessageComplete callback succeeded");
309
- } catch (e) {
310
- console.error("endOfTurnCallback callback failed:", e);
325
+ switch (part.type) {
326
+ case "tool-input-available":
327
+ toolCallCount++;
328
+ logger.debug(
329
+ `Stream part: tool-input-available (tool=${part.toolName}, callId=${part.toolCallId}, count=${toolCallCount})`,
330
+ );
331
+ await scheduleToolCall(
332
+ ctx,
333
+ {
334
+ toolCallId: part.toolCallId,
335
+ toolName: part.toolName,
336
+ args: part.input,
337
+ msgId: msgId,
338
+ threadId: args.threadId,
339
+ saveDelta: !!saveStreamDeltas,
340
+ },
341
+ toolDefinitions,
342
+ logger,
343
+ );
344
+ break;
345
+ case "finish":
346
+ finishReason = part.finishReason;
347
+ logger.debug(`Stream part: finish (reason=${finishReason})`);
348
+ break;
349
+ case "error":
350
+ logger.error("Stream error:", part.errorText);
351
+ throw new Error(`Stream error: ${part.errorText}`);
352
+ default:
353
+ break;
354
+ }
311
355
  }
312
- }
356
+ logger.debug(`Stream iteration complete (toolCallCount=${toolCallCount}, finishReason=${finishReason})`);
313
357
 
314
- // Save the assistant response if we have one
315
- if (responseMessage.role === "assistant" && responseMessage.parts.length > 0) {
358
+ if (!responseMessage) {
359
+ throw new Error("No response message");
360
+ }
316
361
  logger.debug(
317
- `Saving assistant response (id=${responseMessage.id}, parts=${responseMessage.parts.length}, seq=${stream.seq})`,
362
+ `Response message received (role=${responseMessage.role}, parts=${responseMessage.parts.length})`,
318
363
  );
319
- await ctx.runMutation(component.messages.add, {
320
- threadId: args.threadId,
321
- streaming: true,
322
- msg: responseMessage,
323
- overwrite: true,
324
- committedSeq: stream.seq,
325
- });
326
- logger.debug("Applying tool outcomes after saving response...");
327
- await ctx.runMutation(component.messages.applyToolOutcomes, {
328
- threadId: args.threadId,
329
- });
330
- }
331
364
 
332
- // Handle tool calls
333
- if (toolCallCount > 0) {
334
- logger.debug(`Setting thread status to awaiting_tool_results (${toolCallCount} tool calls)`);
335
- finalStatus = "awaiting_tool_results";
336
- } else if (finishReason && finishReason !== "tool-calls") {
337
- logger.debug(`No tool calls, setting thread status to completed (finishReason=${finishReason})`);
338
- finalStatus = "completed";
339
- if (turnCompleteHandlerCallback) {
340
- logger.debug("Invoking onTurnComplete callback...");
365
+ const providerMetadata = await getStreamTextProviderMetadata(result);
366
+ const usage = await getStreamTextUsage(result, providerMetadata);
367
+ logger.debug(`Usage info: ${usage ? JSON.stringify(usage) : "none"}`);
368
+ if (usage && usageHandlerCallback) {
369
+ logger.debug("Invoking onMessageComplete callback...");
341
370
  try {
342
- await turnCompleteHandlerCallback(ctx as ActionCtx, {
371
+ await usageHandlerCallback(ctx as ActionCtx, {
343
372
  threadId: args.threadId,
344
373
  streamId: args.streamId,
374
+ message: responseMessage,
375
+ usage,
345
376
  providerMetadata: serializeForConvex(providerMetadata),
346
- finishReason,
347
377
  });
348
- logger.debug("onTurnComplete callback succeeded");
378
+ logger.debug("onMessageComplete callback succeeded");
349
379
  } catch (e) {
350
- console.error("turnCompleteHandler callback failed:", e);
380
+ console.error("endOfTurnCallback callback failed:", e);
351
381
  }
352
382
  }
353
- } else {
354
- logger.debug(`Unhandled end state: toolCallCount=${toolCallCount}, finishReason=${finishReason}`);
355
- }
356
383
 
357
- // Mark the stream finished only after all end-of-turn writes are persisted.
358
- logger.debug("Finishing delta stream...");
359
- await streamer.finish();
384
+ if (responseMessage.role === "assistant" && responseMessage.parts.length > 0) {
385
+ logger.debug(
386
+ `Saving assistant response (id=${responseMessage.id}, parts=${responseMessage.parts.length}, seq=${stream.seq})`,
387
+ );
388
+ await ctx.runMutation(component.messages.add, {
389
+ threadId: args.threadId,
390
+ streaming: true,
391
+ msg: responseMessage,
392
+ overwrite: true,
393
+ committedSeq: stream.seq,
394
+ });
395
+ logger.debug("Applying tool outcomes after saving response...");
396
+ await ctx.runMutation(component.messages.applyToolOutcomes, {
397
+ threadId: args.threadId,
398
+ });
399
+ }
360
400
 
361
- logger.debug("Stream handler completed successfully");
362
- return null;
401
+ if (toolCallCount > 0) {
402
+ logger.debug(`Setting thread status to awaiting_tool_results (${toolCallCount} tool calls)`);
403
+ finalStatus = "awaiting_tool_results";
404
+ } else if (finishReason && finishReason !== "tool-calls") {
405
+ logger.debug(`No tool calls, setting thread status to completed (finishReason=${finishReason})`);
406
+ finalStatus = "completed";
407
+ if (turnCompleteHandlerCallback) {
408
+ logger.debug("Invoking onTurnComplete callback...");
409
+ try {
410
+ await turnCompleteHandlerCallback(ctx as ActionCtx, {
411
+ threadId: args.threadId,
412
+ streamId: args.streamId,
413
+ providerMetadata: serializeForConvex(providerMetadata),
414
+ finishReason,
415
+ });
416
+ logger.debug("onTurnComplete callback succeeded");
417
+ } catch (e) {
418
+ console.error("turnCompleteHandler callback failed:", e);
419
+ }
420
+ }
421
+ } else {
422
+ logger.debug(`Unhandled end state: toolCallCount=${toolCallCount}, finishReason=${finishReason}`);
423
+ }
424
+
425
+ logger.debug("Finishing delta stream...");
426
+ await streamer.finish();
427
+ await ctx.runMutation(component.threads.clearRetryState, {
428
+ threadId: args.threadId as Id<"threads">,
429
+ });
430
+ logger.debug("Stream handler completed successfully");
431
+ return null;
432
+ } catch (attemptError) {
433
+ const normalizedError = normalizeErrorMessage(attemptError);
434
+ const defaultClassification = classifyRetryErrorDefault(attemptError);
435
+ let decision: RetryDecision = defaultClassification.retryable
436
+ ? { action: "retry" }
437
+ : {
438
+ action: "fail",
439
+ kind: defaultClassification.kind,
440
+ requiresExplicitHandling: defaultClassification.requiresExplicitHandling,
441
+ };
442
+ if (retryEnabled && retry?.classify) {
443
+ try {
444
+ decision = await retry.classify(ctx as ActionCtx, {
445
+ threadId: args.threadId,
446
+ streamId: args.streamId,
447
+ attempt: retryAttempt,
448
+ maxAttempts: maxRetryAttempts,
449
+ toolCallsScheduled: toolCallCount,
450
+ streamPartCount,
451
+ error: attemptError,
452
+ normalizedError,
453
+ defaultClassification,
454
+ defaultDecision: decision,
455
+ });
456
+ } catch (classifierError) {
457
+ logger.warn(
458
+ `retry.classify failed; falling back to default classification: ${normalizeErrorMessage(classifierError)}`,
459
+ );
460
+ }
461
+ }
462
+
463
+ const retryAfterToolCalls = retry !== false && (retry?.retryAfterToolCalls ?? false);
464
+ const retryBlockedByToolCalls = toolCallCount > 0 && !retryAfterToolCalls;
465
+ const canRetry =
466
+ retryEnabled &&
467
+ decision.action === "retry" &&
468
+ retryAttempt < maxRetryAttempts &&
469
+ !retryBlockedByToolCalls &&
470
+ streamPartCount === 0;
471
+
472
+ if (canRetry) {
473
+ const retryAfterDelayMs = extractRetryAfterDelayMs(defaultClassification.signal);
474
+ const delayMs = clampDelayMs(
475
+ (decision.action === "retry" ? decision.delayMs : undefined) ??
476
+ retryAfterDelayMs ??
477
+ computeBackoffDelayMs(retryAttempt, retry?.backoff),
478
+ );
479
+ classifiedErrorKind = defaultClassification.kind;
480
+ classifiedRetryable = true;
481
+ classifiedRequiresExplicitHandling = false;
482
+ classifiedAttempt = retryAttempt;
483
+ classifiedMaxAttempts = maxRetryAttempts;
484
+ logger.warn(
485
+ `Scheduling retry after recoverable error (attempt ${retryAttempt}/${maxRetryAttempts}, delay=${delayMs}ms): ${normalizedError}`,
486
+ );
487
+ if (onRetryCallback) {
488
+ try {
489
+ await onRetryCallback(ctx as ActionCtx, {
490
+ scope: "stream",
491
+ threadId: args.threadId,
492
+ streamId: args.streamId,
493
+ attempt: retryAttempt,
494
+ maxAttempts: maxRetryAttempts,
495
+ delayMs,
496
+ kind: defaultClassification.kind,
497
+ error: normalizedError,
498
+ });
499
+ } catch (onRetryError) {
500
+ console.error("onRetry callback failed:", onRetryError);
501
+ }
502
+ }
503
+
504
+ await ctx.runMutation(component.threads.scheduleRetry, {
505
+ threadId: args.threadId as Id<"threads">,
506
+ scope: "stream",
507
+ attempt: retryAttempt,
508
+ maxAttempts: maxRetryAttempts,
509
+ nextRetryAt: Date.now() + delayMs,
510
+ error: normalizedError,
511
+ kind: defaultClassification.kind,
512
+ retryable: true,
513
+ requiresExplicitHandling: false,
514
+ });
515
+ await streamer.fail(normalizedError);
516
+ logger.debug("Retry scheduled; ending current stream invocation");
517
+ return null;
518
+ }
519
+
520
+ classifiedErrorKind =
521
+ decision.action === "fail" ? (decision.kind ?? defaultClassification.kind) : defaultClassification.kind;
522
+ classifiedRetryable = false;
523
+ classifiedRequiresExplicitHandling =
524
+ decision.action === "fail"
525
+ ? (decision.requiresExplicitHandling ?? defaultClassification.requiresExplicitHandling)
526
+ : defaultClassification.requiresExplicitHandling;
527
+ classifiedAttempt = retryAttempt;
528
+ classifiedMaxAttempts = maxRetryAttempts;
529
+ throw attemptError;
530
+ }
363
531
  } catch (error) {
364
- const errorMessage = error instanceof Error ? error.message : String(error);
365
- const normalizedError = errorMessage || "Unknown error";
532
+ const normalizedError = normalizeErrorMessage(error);
366
533
  logger.error("Error in stream handler:", normalizedError);
534
+ await ctx.runMutation(component.threads.clearRetryState, {
535
+ threadId: args.threadId as Id<"threads">,
536
+ });
367
537
  try {
368
538
  await streamer.fail(normalizedError);
369
539
  } catch (streamAbortError) {
@@ -379,6 +549,11 @@ export function streamHandlerAction(
379
549
  threadId: args.threadId,
380
550
  streamId: args.streamId,
381
551
  error: normalizedError,
552
+ kind: classifiedErrorKind,
553
+ retryable: classifiedRetryable,
554
+ requiresExplicitHandling: classifiedRequiresExplicitHandling,
555
+ attempt: classifiedAttempt,
556
+ maxAttempts: classifiedMaxAttempts,
382
557
  });
383
558
  } catch (e) {
384
559
  console.error("errorHandler callback failed:", e);
@@ -1,7 +1,7 @@
1
1
  import type { UIMessageChunk } from "ai";
2
2
  import type { ComponentApi } from "../component/_generated/component";
3
3
  import type { Doc, Id } from "../component/_generated/dataModel";
4
- import { Logger } from "../logger";
4
+ import { Logger } from "../utils/logger";
5
5
  import type { ActionCtx } from "./types";
6
6
 
7
7
  export class Streamer {
@@ -13,6 +13,7 @@ export function createActionTool<INPUT, OUTPUT>(def: {
13
13
  description: string;
14
14
  args: z.ZodType<INPUT>;
15
15
  handler: FunctionReference<"action", "internal" | "public">;
16
+ retry?: true | SyncTool<INPUT, OUTPUT>["retry"];
16
17
  }): SyncTool<INPUT, OUTPUT> {
17
18
  // Convert the Zod schema to JSON Schema format using Zod v4's native method
18
19
  const jsonSchemaObj = z.toJSONSchema(def.args) as Record<string, unknown>;
@@ -23,6 +24,12 @@ export function createActionTool<INPUT, OUTPUT>(def: {
23
24
  description: def.description,
24
25
  parameters: cleanSchema,
25
26
  handler: def.handler,
27
+ retry:
28
+ def.retry === true
29
+ ? {
30
+ enabled: true,
31
+ }
32
+ : def.retry,
26
33
  };
27
34
  }
28
35
 
@@ -56,19 +63,54 @@ export type ToolDefinition = {
56
63
  name: string;
57
64
  description: string;
58
65
  parameters: unknown;
59
- } & ({ type: "sync"; handler: string } | { type: "async"; callback: string });
66
+ } & (
67
+ | {
68
+ type: "sync";
69
+ handler: string;
70
+ retry?: {
71
+ enabled: true;
72
+ maxAttempts?: number;
73
+ backoff?:
74
+ | {
75
+ strategy?: "fixed";
76
+ delayMs: number;
77
+ jitter?: boolean;
78
+ }
79
+ | {
80
+ strategy: "exponential";
81
+ initialDelayMs: number;
82
+ multiplier?: number;
83
+ maxDelayMs?: number;
84
+ jitter?: boolean;
85
+ };
86
+ shouldRetryError?: string;
87
+ };
88
+ }
89
+ | { type: "async"; callback: string }
90
+ );
60
91
 
61
92
  export async function buildToolDefinitions(tools: Record<string, DurableTool>): Promise<Array<ToolDefinition>> {
62
93
  if (!tools) return [];
63
94
 
64
95
  const makeToolDef = async (name: string, tool: DurableTool): Promise<ToolDefinition> => {
65
96
  if (tool.type === "sync") {
97
+ const shouldRetryError = tool.retry?.shouldRetryError
98
+ ? await serializeFunctionRef(tool.retry.shouldRetryError)
99
+ : undefined;
66
100
  return {
67
101
  type: "sync",
68
102
  name,
69
103
  description: tool.description,
70
104
  parameters: tool.parameters,
71
105
  handler: await serializeFunctionRef(tool.handler),
106
+ retry: tool.retry
107
+ ? {
108
+ enabled: true,
109
+ maxAttempts: tool.retry.maxAttempts,
110
+ backoff: tool.retry.backoff,
111
+ shouldRetryError,
112
+ }
113
+ : undefined,
72
114
  };
73
115
  }
74
116
  return {