convex-durable-agents 0.2.3 → 0.2.5

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