convex-durable-agents 0.2.2 → 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 (75) hide show
  1. package/README.md +81 -7
  2. package/dist/client/api.d.ts.map +1 -1
  3. package/dist/client/api.js +26 -6
  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 -116
  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 +90 -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/messages.d.ts +1 -0
  25. package/dist/component/messages.d.ts.map +1 -1
  26. package/dist/component/messages.js +9 -0
  27. package/dist/component/messages.js.map +1 -1
  28. package/dist/component/schema.d.ts +70 -2
  29. package/dist/component/schema.d.ts.map +1 -1
  30. package/dist/component/schema.js +21 -0
  31. package/dist/component/schema.js.map +1 -1
  32. package/dist/component/streams.js +2 -2
  33. package/dist/component/streams.js.map +1 -1
  34. package/dist/component/threads.d.ts +92 -2
  35. package/dist/component/threads.d.ts.map +1 -1
  36. package/dist/component/threads.js +83 -2
  37. package/dist/component/threads.js.map +1 -1
  38. package/dist/component/tool_calls.d.ts +54 -3
  39. package/dist/component/tool_calls.d.ts.map +1 -1
  40. package/dist/component/tool_calls.js +358 -40
  41. package/dist/component/tool_calls.js.map +1 -1
  42. package/dist/utils/logger.d.ts.map +1 -0
  43. package/dist/utils/logger.js.map +1 -0
  44. package/dist/utils/retry.d.ts +69 -0
  45. package/dist/utils/retry.d.ts.map +1 -0
  46. package/dist/utils/retry.js +404 -0
  47. package/dist/utils/retry.js.map +1 -0
  48. package/dist/utils/streaming.d.ts +4 -0
  49. package/dist/utils/streaming.d.ts.map +1 -0
  50. package/dist/utils/streaming.js +4 -0
  51. package/dist/utils/streaming.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/client/api.ts +26 -7
  54. package/src/client/handler.ts +308 -132
  55. package/src/client/streamer.ts +1 -1
  56. package/src/client/tools.ts +43 -1
  57. package/src/client/types.ts +60 -0
  58. package/src/component/_generated/component.ts +104 -0
  59. package/src/component/agent.ts +24 -2
  60. package/src/component/messages.ts +9 -0
  61. package/src/component/schema.ts +22 -0
  62. package/src/component/streams.ts +2 -2
  63. package/src/component/threads.ts +92 -3
  64. package/src/component/tool_calls.ts +433 -49
  65. package/src/utils/retry.ts +528 -0
  66. package/src/{streaming.ts → utils/streaming.ts} +2 -3
  67. package/dist/logger.d.ts.map +0 -1
  68. package/dist/logger.js.map +0 -1
  69. package/dist/streaming.d.ts +0 -3
  70. package/dist/streaming.d.ts.map +0 -1
  71. package/dist/streaming.js +0 -4
  72. package/dist/streaming.js.map +0 -1
  73. /package/dist/{logger.d.ts → utils/logger.d.ts} +0 -0
  74. /package/dist/{logger.js → utils/logger.js} +0 -0
  75. /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,157 +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,
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);
307
+ }
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),
274
321
  );
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;
322
+ }
323
+ await streamer.process(part);
324
+
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
+ }
286
355
  }
287
- }
288
- logger.debug(`Stream iteration complete (toolCallCount=${toolCallCount}, finishReason=${finishReason})`);
356
+ logger.debug(`Stream iteration complete (toolCallCount=${toolCallCount}, finishReason=${finishReason})`);
289
357
 
290
- // Finish the delta stream
291
- logger.debug("Finishing delta stream...");
292
- await streamer.finish();
358
+ if (!responseMessage) {
359
+ throw new Error("No response message");
360
+ }
361
+ logger.debug(
362
+ `Response message received (role=${responseMessage.role}, parts=${responseMessage.parts.length})`,
363
+ );
293
364
 
294
- if (!responseMessage) {
295
- throw new Error("No response message");
296
- }
297
- logger.debug(`Response message received (role=${responseMessage.role}, parts=${responseMessage.parts.length})`);
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...");
370
+ try {
371
+ await usageHandlerCallback(ctx as ActionCtx, {
372
+ threadId: args.threadId,
373
+ streamId: args.streamId,
374
+ message: responseMessage,
375
+ usage,
376
+ providerMetadata: serializeForConvex(providerMetadata),
377
+ });
378
+ logger.debug("onMessageComplete callback succeeded");
379
+ } catch (e) {
380
+ console.error("endOfTurnCallback callback failed:", e);
381
+ }
382
+ }
298
383
 
299
- const providerMetadata = await getStreamTextProviderMetadata(result);
300
- const usage = await getStreamTextUsage(result, providerMetadata);
301
- logger.debug(`Usage info: ${usage ? JSON.stringify(usage) : "none"}`);
302
- if (usage && usageHandlerCallback) {
303
- logger.debug("Invoking onMessageComplete callback...");
304
- try {
305
- await usageHandlerCallback(ctx as ActionCtx, {
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, {
306
397
  threadId: args.threadId,
307
- streamId: args.streamId,
308
- message: responseMessage,
309
- usage,
310
- providerMetadata: serializeForConvex(providerMetadata),
311
398
  });
312
- logger.debug("onMessageComplete callback succeeded");
313
- } catch (e) {
314
- console.error("endOfTurnCallback callback failed:", e);
315
399
  }
316
- }
317
400
 
318
- // Save the assistant response if we have one
319
- if (responseMessage.role === "assistant" && responseMessage.parts.length > 0) {
320
- logger.debug(
321
- `Saving assistant response (id=${responseMessage.id}, parts=${responseMessage.parts.length}, seq=${stream.seq})`,
322
- );
323
- await ctx.runMutation(component.messages.add, {
324
- threadId: args.threadId,
325
- msg: responseMessage,
326
- overwrite: true,
327
- committedSeq: stream.seq,
328
- });
329
- logger.debug("Applying tool outcomes after saving response...");
330
- await ctx.runMutation(component.messages.applyToolOutcomes, {
331
- threadId: args.threadId,
332
- });
333
- }
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
+ }
334
424
 
335
- // Handle tool calls
336
- if (toolCallCount > 0) {
337
- logger.debug(`Setting thread status to awaiting_tool_results (${toolCallCount} tool calls)`);
338
- finalStatus = "awaiting_tool_results";
339
- } else if (finishReason && finishReason !== "tool-calls") {
340
- logger.debug(`No tool calls, setting thread status to completed (finishReason=${finishReason})`);
341
- finalStatus = "completed";
342
- if (turnCompleteHandlerCallback) {
343
- logger.debug("Invoking onTurnComplete callback...");
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) {
344
443
  try {
345
- await turnCompleteHandlerCallback(ctx as ActionCtx, {
444
+ decision = await retry.classify(ctx as ActionCtx, {
346
445
  threadId: args.threadId,
347
446
  streamId: args.streamId,
348
- providerMetadata: serializeForConvex(providerMetadata),
349
- finishReason,
447
+ attempt: retryAttempt,
448
+ maxAttempts: maxRetryAttempts,
449
+ toolCallsScheduled: toolCallCount,
450
+ streamPartCount,
451
+ error: attemptError,
452
+ normalizedError,
453
+ defaultClassification,
454
+ defaultDecision: decision,
350
455
  });
351
- logger.debug("onTurnComplete callback succeeded");
352
- } catch (e) {
353
- console.error("turnCompleteHandler callback failed:", e);
456
+ } catch (classifierError) {
457
+ logger.warn(
458
+ `retry.classify failed; falling back to default classification: ${normalizeErrorMessage(classifierError)}`,
459
+ );
354
460
  }
355
461
  }
356
- } else {
357
- logger.debug(`Unhandled end state: toolCallCount=${toolCallCount}, finishReason=${finishReason}`);
358
- }
359
462
 
360
- logger.debug("Stream handler completed successfully");
361
- return null;
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
+ }
362
531
  } catch (error) {
363
- const errorMessage = error instanceof Error ? error.message : String(error);
364
- const normalizedError = errorMessage || "Unknown error";
532
+ const normalizedError = normalizeErrorMessage(error);
365
533
  logger.error("Error in stream handler:", normalizedError);
534
+ await ctx.runMutation(component.threads.clearRetryState, {
535
+ threadId: args.threadId as Id<"threads">,
536
+ });
366
537
  try {
367
538
  await streamer.fail(normalizedError);
368
539
  } catch (streamAbortError) {
@@ -378,6 +549,11 @@ export function streamHandlerAction(
378
549
  threadId: args.threadId,
379
550
  streamId: args.streamId,
380
551
  error: normalizedError,
552
+ kind: classifiedErrorKind,
553
+ retryable: classifiedRetryable,
554
+ requiresExplicitHandling: classifiedRequiresExplicitHandling,
555
+ attempt: classifiedAttempt,
556
+ maxAttempts: classifiedMaxAttempts,
381
557
  });
382
558
  } catch (e) {
383
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 {