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
@@ -1,7 +1,8 @@
1
- import type { UIMessagePart } from "ai";
1
+ import type { ProviderMetadata, UIMessagePart } from "ai";
2
2
  import type { FunctionHandle } from "convex/server";
3
3
  import { v } from "convex/values";
4
- import { Logger } from "../logger.js";
4
+ import { Logger } from "../utils/logger.js";
5
+ import { extractToolErrorInfo, isRetryableDecision, isRetryableToolErrorDefault } from "../utils/retry.js";
5
6
  import { api, internal } from "./_generated/api.js";
6
7
  import type { Doc, Id } from "./_generated/dataModel.js";
7
8
  import { internalAction, internalMutation, type MutationCtx, mutation, query } from "./_generated/server.js";
@@ -14,6 +15,72 @@ const MINUTE = 60 * SECOND;
14
15
  const TOOL_CALL_TIMEOUT_MS = 30 * MINUTE;
15
16
  const ASYNC_CALLBACK_MAX_ATTEMPTS = 3;
16
17
  const ASYNC_CALLBACK_RETRY_BASE_MS = 5 * SECOND;
18
+ const SYNC_TOOL_MAX_ATTEMPTS = 3;
19
+ const SYNC_TOOL_RETRY_INITIAL_BACKOFF_MS = 500;
20
+
21
+ type RetryBackoffPolicy =
22
+ | {
23
+ strategy?: "fixed";
24
+ delayMs: number;
25
+ jitter?: boolean;
26
+ }
27
+ | {
28
+ strategy: "exponential";
29
+ initialDelayMs: number;
30
+ multiplier?: number;
31
+ maxDelayMs?: number;
32
+ jitter?: boolean;
33
+ };
34
+
35
+ type SyncToolRetryPolicy = {
36
+ enabled: true;
37
+ maxAttempts?: number;
38
+ backoff?: RetryBackoffPolicy;
39
+ shouldRetryError?: string;
40
+ };
41
+
42
+ function normalizeSyncToolRetryPolicy(value: unknown): SyncToolRetryPolicy | undefined {
43
+ if (value == null || typeof value !== "object") {
44
+ return undefined;
45
+ }
46
+ const obj = value as Record<string, unknown>;
47
+ if (obj.enabled !== true) {
48
+ return undefined;
49
+ }
50
+ return {
51
+ enabled: true,
52
+ maxAttempts: typeof obj.maxAttempts === "number" ? obj.maxAttempts : undefined,
53
+ backoff: (obj.backoff as RetryBackoffPolicy | undefined) ?? undefined,
54
+ shouldRetryError: typeof obj.shouldRetryError === "string" ? obj.shouldRetryError : undefined,
55
+ };
56
+ }
57
+
58
+ function clampDelayMs(value: number): number {
59
+ if (!Number.isFinite(value) || value < 0) return 0;
60
+ return Math.floor(value);
61
+ }
62
+
63
+ function computeRetryDelayMs(attempt: number, backoff?: RetryBackoffPolicy): number {
64
+ const policy = backoff ?? {
65
+ strategy: "exponential" as const,
66
+ initialDelayMs: SYNC_TOOL_RETRY_INITIAL_BACKOFF_MS,
67
+ multiplier: 2,
68
+ maxDelayMs: 10_000,
69
+ jitter: true,
70
+ };
71
+ if ("delayMs" in policy) {
72
+ const delayMs = clampDelayMs(policy.delayMs);
73
+ if (!policy.jitter) return delayMs;
74
+ return Math.floor(Math.random() * (delayMs + 1));
75
+ }
76
+ const initialDelayMs = clampDelayMs(policy.initialDelayMs);
77
+ const multiplier = Number.isFinite(policy.multiplier ?? 2) ? (policy.multiplier ?? 2) : 2;
78
+ const unbounded = initialDelayMs * multiplier ** Math.max(0, attempt - 1);
79
+ const maxDelayMs = policy.maxDelayMs == null ? unbounded : clampDelayMs(policy.maxDelayMs);
80
+ const delayMs = Math.min(unbounded, maxDelayMs);
81
+ if (!policy.jitter) return delayMs;
82
+ return Math.floor(Math.random() * (delayMs + 1));
83
+ }
17
84
 
18
85
  function normalizeToolCallTimeoutMs(timeoutMs: number): number {
19
86
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
@@ -44,16 +111,29 @@ async function cleanupTimeoutFn(ctx: MutationCtx, toolCall: Doc<"tool_calls">):
44
111
  }
45
112
  }
46
113
 
114
+ async function cleanupExecutionRetryFn(ctx: MutationCtx, toolCall: Doc<"tool_calls">): Promise<void> {
115
+ if (!toolCall.executionRetryFnId) {
116
+ return;
117
+ }
118
+ const retryFn = await ctx.db.system.get(toolCall.executionRetryFnId);
119
+ if (retryFn?.state.kind === "pending") {
120
+ await ctx.scheduler.cancel(toolCall.executionRetryFnId);
121
+ }
122
+ }
123
+
47
124
  async function failToolCallIfPending(ctx: MutationCtx, toolCall: Doc<"tool_calls">, error: string): Promise<boolean> {
48
125
  const latest = await ctx.db.get(toolCall._id);
49
126
  if (!latest || latest.status !== "pending") {
50
127
  return false;
51
128
  }
52
129
  await cleanupTimeoutFn(ctx, latest);
130
+ await cleanupExecutionRetryFn(ctx, latest);
53
131
  await ctx.db.patch(latest._id, {
54
132
  error,
55
133
  status: "failed",
56
134
  callbackLastError: error,
135
+ executionRetryFnId: undefined,
136
+ nextRetryAt: undefined,
57
137
  });
58
138
 
59
139
  if (latest.saveDelta) {
@@ -96,6 +176,15 @@ export type ToolCallDoc = {
96
176
  args: unknown;
97
177
  result?: unknown;
98
178
  error?: string;
179
+ status: "pending" | "completed" | "failed";
180
+ callbackAttempt?: number;
181
+ callbackLastError?: string;
182
+ handler?: string;
183
+ executionAttempt?: number;
184
+ executionMaxAttempts?: number;
185
+ executionLastError?: string;
186
+ executionRetryPolicy?: unknown;
187
+ nextRetryAt?: number;
99
188
  };
100
189
 
101
190
  function publicToolCall(toolCall: Doc<"tool_calls">): ToolCallDoc {
@@ -109,6 +198,15 @@ function publicToolCall(toolCall: Doc<"tool_calls">): ToolCallDoc {
109
198
  args: toolCall.args,
110
199
  result: toolCall.result,
111
200
  error: toolCall.error,
201
+ status: toolCall.status,
202
+ callbackAttempt: toolCall.callbackAttempt,
203
+ callbackLastError: toolCall.callbackLastError,
204
+ handler: toolCall.handler,
205
+ executionAttempt: toolCall.executionAttempt,
206
+ executionMaxAttempts: toolCall.executionMaxAttempts,
207
+ executionLastError: toolCall.executionLastError,
208
+ executionRetryPolicy: toolCall.executionRetryPolicy,
209
+ nextRetryAt: toolCall.nextRetryAt,
112
210
  };
113
211
  }
114
212
 
@@ -123,8 +221,71 @@ export const vToolCallDoc = v.object({
123
221
  args: v.any(),
124
222
  result: v.optional(v.any()),
125
223
  error: v.optional(v.string()),
224
+ status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed")),
225
+ callbackAttempt: v.optional(v.number()),
226
+ callbackLastError: v.optional(v.string()),
227
+ handler: v.optional(v.string()),
228
+ executionAttempt: v.optional(v.number()),
229
+ executionMaxAttempts: v.optional(v.number()),
230
+ executionLastError: v.optional(v.string()),
231
+ executionRetryPolicy: v.optional(v.any()),
232
+ nextRetryAt: v.optional(v.number()),
126
233
  });
127
234
 
235
+ type CreateToolCallArgs = {
236
+ threadId: Id<"threads">;
237
+ msgId: string;
238
+ toolCallId: string;
239
+ toolName: string;
240
+ callback?: string;
241
+ handler?: string;
242
+ retry?: SyncToolRetryPolicy;
243
+ args: unknown;
244
+ saveDelta: boolean;
245
+ };
246
+
247
+ async function createToolCallRecord(ctx: MutationCtx, args: CreateToolCallArgs): Promise<Doc<"tool_calls">> {
248
+ const existingToolCall = await ctx.db
249
+ .query("tool_calls")
250
+ .withIndex("by_thread_tool_call_id", (q) => q.eq("threadId", args.threadId).eq("toolCallId", args.toolCallId))
251
+ .first();
252
+ if (existingToolCall) {
253
+ throw new Error(`Tool call ${args.toolCallId} already exists`);
254
+ }
255
+ logger.debug(
256
+ `create: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}, msgId=${args.msgId}`,
257
+ );
258
+ const expiresAt = Date.now() + TOOL_CALL_TIMEOUT_MS;
259
+ const toolCallId = await ctx.db.insert("tool_calls", {
260
+ threadId: args.threadId,
261
+ msgId: args.msgId,
262
+ toolCallId: args.toolCallId,
263
+ toolName: args.toolName,
264
+ callback: args.callback,
265
+ handler: args.handler,
266
+ callbackAttempt: args.callback ? 0 : undefined,
267
+ executionAttempt: args.retry ? 0 : undefined,
268
+ executionMaxAttempts: args.retry?.maxAttempts,
269
+ executionRetryPolicy: args.retry,
270
+ args: args.args,
271
+ saveDelta: args.saveDelta,
272
+ timeoutMs: TOOL_CALL_TIMEOUT_MS,
273
+ expiresAt,
274
+ status: "pending",
275
+ });
276
+ const timeoutFnId = await ctx.scheduler.runAfter(TOOL_CALL_TIMEOUT_MS, internal.tool_calls.failPendingToolCall, {
277
+ threadId: args.threadId,
278
+ toolCallId: args.toolCallId,
279
+ });
280
+ await ctx.db.patch(toolCallId, { timeoutFnId });
281
+
282
+ const toolCall = await ctx.db.get(toolCallId);
283
+ if (!toolCall) {
284
+ throw new Error(`Tool call ${toolCallId} not found after creation`);
285
+ }
286
+ return toolCall;
287
+ }
288
+
128
289
  export const create = mutation({
129
290
  args: {
130
291
  threadId: v.id("threads"),
@@ -132,43 +293,25 @@ export const create = mutation({
132
293
  toolCallId: v.string(),
133
294
  toolName: v.string(),
134
295
  callback: v.optional(v.string()),
296
+ handler: v.optional(v.string()),
297
+ retry: v.optional(v.any()),
135
298
  args: v.any(),
136
299
  saveDelta: v.boolean(),
137
300
  },
138
301
  returns: vToolCallDoc,
139
302
  handler: async (ctx, args) => {
140
- const existingToolCall = await ctx.db
141
- .query("tool_calls")
142
- .withIndex("by_thread_tool_call_id", (q) => q.eq("threadId", args.threadId).eq("toolCallId", args.toolCallId))
143
- .first();
144
- if (existingToolCall) {
145
- throw new Error(`Tool call ${args.toolCallId} already exists`);
146
- }
147
- logger.debug(
148
- `create: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}, msgId=${args.msgId}`,
149
- );
150
- const expiresAt = Date.now() + TOOL_CALL_TIMEOUT_MS;
151
- const toolCallId = await ctx.db.insert("tool_calls", {
303
+ const toolCall = await createToolCallRecord(ctx, {
152
304
  threadId: args.threadId,
153
305
  msgId: args.msgId,
154
306
  toolCallId: args.toolCallId,
155
307
  toolName: args.toolName,
156
308
  callback: args.callback,
157
- callbackAttempt: args.callback ? 0 : undefined,
309
+ handler: args.handler,
310
+ retry: normalizeSyncToolRetryPolicy(args.retry),
158
311
  args: args.args,
159
312
  saveDelta: args.saveDelta,
160
- timeoutMs: TOOL_CALL_TIMEOUT_MS,
161
- expiresAt,
162
- status: "pending",
163
- });
164
- const timeoutFnId = await ctx.scheduler.runAfter(TOOL_CALL_TIMEOUT_MS, internal.tool_calls.failPendingToolCall, {
165
- threadId: args.threadId,
166
- toolCallId: args.toolCallId,
167
313
  });
168
- await ctx.db.patch(toolCallId, { timeoutFnId });
169
-
170
- const toolCall = await ctx.db.get(toolCallId);
171
- return publicToolCall(toolCall!);
314
+ return publicToolCall(toolCall);
172
315
  },
173
316
  });
174
317
 
@@ -189,7 +332,13 @@ export const setResult = mutation({
189
332
  }
190
333
  logger.debug(`setResult: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}`);
191
334
  await cleanupTimeoutFn(ctx, toolCall);
192
- await ctx.db.patch(args.id, { result: args.result, status: "completed" });
335
+ await cleanupExecutionRetryFn(ctx, toolCall);
336
+ await ctx.db.patch(args.id, {
337
+ result: args.result,
338
+ status: "completed",
339
+ executionRetryFnId: undefined,
340
+ nextRetryAt: undefined,
341
+ });
193
342
  return true;
194
343
  },
195
344
  });
@@ -211,7 +360,14 @@ export const setError = mutation({
211
360
  }
212
361
  logger.debug(`setError: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}, error=${args.error}`);
213
362
  await cleanupTimeoutFn(ctx, toolCall);
214
- await ctx.db.patch(args.id, { error: args.error, status: "failed", callbackLastError: args.error });
363
+ await cleanupExecutionRetryFn(ctx, toolCall);
364
+ await ctx.db.patch(args.id, {
365
+ error: args.error,
366
+ status: "failed",
367
+ callbackLastError: args.error,
368
+ executionRetryFnId: undefined,
369
+ nextRetryAt: undefined,
370
+ });
215
371
  return true;
216
372
  },
217
373
  });
@@ -320,6 +476,7 @@ export const scheduleToolCall = mutation({
320
476
  toolName: v.string(),
321
477
  args: v.any(),
322
478
  handler: v.string(),
479
+ retry: v.optional(v.any()),
323
480
  saveDelta: v.boolean(),
324
481
  },
325
482
  returns: v.null(),
@@ -333,11 +490,13 @@ export const scheduleToolCall = mutation({
333
490
  logger.debug(`scheduleToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
334
491
 
335
492
  // Create the tool call record
336
- await ctx.runMutation(api.tool_calls.create, {
493
+ await createToolCallRecord(ctx, {
337
494
  threadId: args.threadId,
338
495
  msgId: args.msgId,
339
496
  toolCallId: args.toolCallId,
340
497
  toolName: args.toolName,
498
+ handler: args.handler,
499
+ retry: normalizeSyncToolRetryPolicy(args.retry),
341
500
  args: args.args,
342
501
  saveDelta: args.saveDelta,
343
502
  });
@@ -382,7 +541,7 @@ export const scheduleAsyncToolCall = mutation({
382
541
  logger.debug(`scheduleAsyncToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
383
542
 
384
543
  // Create the tool call record (will remain pending until addToolResult is called)
385
- await ctx.runMutation(api.tool_calls.create, {
544
+ await createToolCallRecord(ctx, {
386
545
  threadId: args.threadId,
387
546
  toolCallId: args.toolCallId,
388
547
  msgId: args.msgId,
@@ -412,6 +571,139 @@ export const scheduleAsyncToolCall = mutation({
412
571
  },
413
572
  });
414
573
 
574
+ export const updateExecutionRetryState = internalMutation({
575
+ args: {
576
+ threadId: v.id("threads"),
577
+ toolCallId: v.string(),
578
+ executionAttempt: v.number(),
579
+ executionLastError: v.optional(v.string()),
580
+ nextRetryAt: v.optional(v.number()),
581
+ executionRetryFnId: v.optional(v.id("_scheduled_functions")),
582
+ clearNextRetryAt: v.optional(v.boolean()),
583
+ clearExecutionRetryFnId: v.optional(v.boolean()),
584
+ },
585
+ returns: v.null(),
586
+ handler: async (ctx, args) => {
587
+ const toolCall = await getToolCallByScope(ctx, {
588
+ threadId: args.threadId,
589
+ toolCallId: args.toolCallId,
590
+ });
591
+ if (!toolCall || toolCall.status !== "pending") {
592
+ return null;
593
+ }
594
+
595
+ const patch: {
596
+ executionAttempt: number;
597
+ executionLastError?: string;
598
+ nextRetryAt?: number | undefined;
599
+ executionRetryFnId?: Id<"_scheduled_functions"> | undefined;
600
+ } = {
601
+ executionAttempt: args.executionAttempt,
602
+ };
603
+ if (args.executionLastError !== undefined) {
604
+ patch.executionLastError = args.executionLastError;
605
+ }
606
+ if (args.nextRetryAt !== undefined) {
607
+ patch.nextRetryAt = args.nextRetryAt;
608
+ }
609
+ if (args.executionRetryFnId !== undefined) {
610
+ patch.executionRetryFnId = args.executionRetryFnId;
611
+ }
612
+ if (args.clearNextRetryAt) {
613
+ patch.nextRetryAt = undefined;
614
+ }
615
+ if (args.clearExecutionRetryFnId) {
616
+ patch.executionRetryFnId = undefined;
617
+ }
618
+ await ctx.db.patch(toolCall._id, patch);
619
+ return null;
620
+ },
621
+ });
622
+
623
+ export const scheduleExecutionRetry = internalMutation({
624
+ args: {
625
+ threadId: v.id("threads"),
626
+ toolCallId: v.string(),
627
+ handler: v.string(),
628
+ executionAttempt: v.number(),
629
+ executionLastError: v.string(),
630
+ nextRetryAt: v.number(),
631
+ },
632
+ returns: v.null(),
633
+ handler: async (ctx, args) => {
634
+ const toolCall = await getToolCallByScope(ctx, {
635
+ threadId: args.threadId,
636
+ toolCallId: args.toolCallId,
637
+ });
638
+ if (!toolCall || toolCall.status !== "pending") {
639
+ return null;
640
+ }
641
+
642
+ await cleanupExecutionRetryFn(ctx, toolCall);
643
+ const delayMs = Math.max(0, args.nextRetryAt - Date.now());
644
+ const executionRetryFnId = await ctx.scheduler.runAfter(delayMs, internal.tool_calls.executeToolCall, {
645
+ threadId: args.threadId,
646
+ toolCallId: args.toolCallId,
647
+ handler: args.handler,
648
+ });
649
+
650
+ await ctx.db.patch(toolCall._id, {
651
+ executionAttempt: args.executionAttempt,
652
+ executionLastError: args.executionLastError,
653
+ nextRetryAt: args.nextRetryAt,
654
+ executionRetryFnId,
655
+ });
656
+ return null;
657
+ },
658
+ });
659
+
660
+ export const resumePendingSyncToolExecutions = mutation({
661
+ args: {
662
+ limit: v.optional(v.number()),
663
+ },
664
+ returns: v.number(),
665
+ handler: async (ctx, args) => {
666
+ const limit = Math.max(1, Math.floor(args.limit ?? 100));
667
+ const pending = await ctx.db
668
+ .query("tool_calls")
669
+ .withIndex("by_status_only", (q) => q.eq("status", "pending"))
670
+ .take(limit * 2);
671
+
672
+ let resumed = 0;
673
+ const now = Date.now();
674
+ for (const toolCall of pending) {
675
+ if (resumed >= limit) {
676
+ break;
677
+ }
678
+ if (!toolCall.handler) {
679
+ continue;
680
+ }
681
+ if (toolCall.executionRetryFnId) {
682
+ const retryFn = await ctx.db.system.get(toolCall.executionRetryFnId);
683
+ if (retryFn?.state.kind === "pending") {
684
+ continue;
685
+ }
686
+ }
687
+ const nextRetryAt = toolCall.nextRetryAt ?? now;
688
+ const delayMs = Math.max(0, nextRetryAt - now);
689
+ const executionRetryFnId = await ctx.scheduler.runAfter(delayMs, internal.tool_calls.executeToolCall, {
690
+ threadId: toolCall.threadId,
691
+ toolCallId: toolCall.toolCallId,
692
+ handler: toolCall.handler,
693
+ });
694
+ await ctx.db.patch(toolCall._id, {
695
+ executionRetryFnId,
696
+ });
697
+ resumed += 1;
698
+ }
699
+
700
+ if (resumed > 0) {
701
+ logger.warn(`resumePendingSyncToolExecutions: resumed ${resumed} pending sync tool call(s)`);
702
+ }
703
+ return resumed;
704
+ },
705
+ });
706
+
415
707
  /**
416
708
  * Execute a tool call
417
709
  */
@@ -423,8 +715,6 @@ export const executeToolCall = internalAction({
423
715
  },
424
716
  returns: v.null(),
425
717
  handler: async (ctx, args) => {
426
- logger.debug(`executeToolCall: callId=${args.toolCallId}, thread=${args.threadId}`);
427
-
428
718
  // Get the tool call record
429
719
  const toolCall = await ctx.runQuery(api.tool_calls.getByToolCallId, {
430
720
  threadId: args.threadId,
@@ -434,34 +724,120 @@ export const executeToolCall = internalAction({
434
724
  if (!toolCall) {
435
725
  throw new Error(`Tool call ${args.toolCallId} not found`);
436
726
  }
727
+ if (toolCall.status !== "pending") {
728
+ logger.debug(`executeToolCall: skipping callId=${args.toolCallId}, status already terminal (${toolCall.status})`);
729
+ return null;
730
+ }
437
731
  logger.debug(`executeToolCall: tool=${toolCall.toolName}`);
438
732
 
439
- try {
440
- // Execute the tool handler
441
- // The handler string is passed from the client and we need to resolve it
442
- // For now, we'll use ctx.runAction with a dynamic reference
443
- // This requires the handler to be a proper function reference string
444
- const toolArgs = typeof toolCall.args === "object" && toolCall.args !== null ? toolCall.args : {};
733
+ const thread = await ctx.runQuery(api.threads.get, {
734
+ threadId: args.threadId,
735
+ });
736
+ if (!thread) {
737
+ throw new Error(`Thread ${args.threadId} not found`);
738
+ }
739
+ if (thread.stopSignal || thread.status === "stopped") {
740
+ await ctx.runMutation(api.tool_calls.addToolError, {
741
+ threadId: args.threadId,
742
+ toolCallId: toolCall.toolCallId,
743
+ error: "Tool execution cancelled because the thread was stopped",
744
+ });
745
+ return null;
746
+ }
445
747
 
748
+ const handler = toolCall.handler ?? args.handler;
749
+ const retryPolicy = normalizeSyncToolRetryPolicy(toolCall.executionRetryPolicy);
750
+ const retryEnabled = retryPolicy?.enabled === true;
751
+ const maxAttempts = retryEnabled ? Math.max(1, retryPolicy.maxAttempts ?? SYNC_TOOL_MAX_ATTEMPTS) : 1;
752
+ const toolArgs = typeof toolCall.args === "object" && toolCall.args !== null ? toolCall.args : {};
753
+ const attempt = Math.max(1, (toolCall.executionAttempt ?? 0) + 1);
754
+ await ctx.runMutation(internal.tool_calls.updateExecutionRetryState, {
755
+ threadId: args.threadId,
756
+ toolCallId: args.toolCallId,
757
+ executionAttempt: attempt,
758
+ clearNextRetryAt: true,
759
+ clearExecutionRetryFnId: true,
760
+ });
761
+ logger.debug(
762
+ `executeToolCall: callId=${args.toolCallId}, thread=${args.threadId}, attempt=${attempt}/${maxAttempts}`,
763
+ );
764
+
765
+ try {
446
766
  logger.debug(`executeToolCall: invoking handler for callId=${args.toolCallId}`);
447
- const result = await ctx.runAction(args.handler as FunctionHandle<"action">, toolArgs as Record<string, unknown>);
767
+ const result = await ctx.runAction(handler as FunctionHandle<"action">, toolArgs as Record<string, unknown>);
448
768
  logger.debug(`executeToolCall: handler succeeded for callId=${args.toolCallId}`);
449
769
  await ctx.runMutation(api.tool_calls.addToolResult, {
450
770
  threadId: args.threadId,
451
771
  result,
452
772
  toolCallId: toolCall.toolCallId,
453
773
  });
774
+ return null;
454
775
  } catch (e) {
455
- const error = e instanceof Error ? e.message : String(e);
456
- logger.debug(`executeToolCall: handler failed for callId=${args.toolCallId}: ${error}`);
776
+ const errorInfo = extractToolErrorInfo(e);
777
+ const error = errorInfo.message;
778
+ logger.debug(`executeToolCall: handler failed for callId=${args.toolCallId} (attempt=${attempt}): ${error}`);
779
+
780
+ let retryable = false;
781
+ if (retryEnabled) {
782
+ if (retryPolicy.shouldRetryError) {
783
+ try {
784
+ const decision = await ctx.runAction(retryPolicy.shouldRetryError as FunctionHandle<"action">, {
785
+ threadId: args.threadId,
786
+ toolCallId: args.toolCallId,
787
+ toolName: toolCall.toolName,
788
+ args: toolCall.args,
789
+ error,
790
+ attempt,
791
+ maxAttempts,
792
+ });
793
+ retryable = isRetryableDecision(decision);
794
+ } catch (classifierError) {
795
+ logger.warn(
796
+ `executeToolCall: shouldRetryError failed for callId=${args.toolCallId}, falling back to default classifier: ${
797
+ classifierError instanceof Error ? classifierError.message : String(classifierError)
798
+ }`,
799
+ );
800
+ retryable = isRetryableToolErrorDefault(errorInfo);
801
+ }
802
+ } else {
803
+ retryable = isRetryableToolErrorDefault(errorInfo);
804
+ }
805
+ }
806
+
807
+ if (retryEnabled && retryable && attempt < maxAttempts) {
808
+ const delayMs = computeRetryDelayMs(attempt, retryPolicy.backoff);
809
+ const nextRetryAt = Date.now() + delayMs;
810
+ await ctx.runMutation(internal.tool_calls.scheduleExecutionRetry, {
811
+ threadId: args.threadId,
812
+ toolCallId: args.toolCallId,
813
+ handler,
814
+ executionAttempt: attempt,
815
+ executionLastError: error,
816
+ nextRetryAt,
817
+ });
818
+ logger.warn(
819
+ `executeToolCall: scheduled retry for callId=${args.toolCallId} in ${delayMs}ms (attempt ${
820
+ attempt + 1
821
+ }/${maxAttempts})`,
822
+ );
823
+ return null;
824
+ }
825
+
826
+ await ctx.runMutation(internal.tool_calls.updateExecutionRetryState, {
827
+ threadId: args.threadId,
828
+ toolCallId: args.toolCallId,
829
+ executionAttempt: attempt,
830
+ executionLastError: error,
831
+ clearNextRetryAt: true,
832
+ clearExecutionRetryFnId: true,
833
+ });
457
834
  await ctx.runMutation(api.tool_calls.addToolError, {
458
835
  threadId: args.threadId,
459
836
  error,
460
837
  toolCallId: toolCall.toolCallId,
461
838
  });
839
+ return null;
462
840
  }
463
-
464
- return null;
465
841
  },
466
842
  });
467
843
 
@@ -623,6 +999,7 @@ export const onToolComplete = internalMutation({
623
999
  status: "stopped",
624
1000
  activeStream: null,
625
1001
  continue: false,
1002
+ retryState: undefined,
626
1003
  });
627
1004
  if (thread.onStatusChangeHandle && previousStatus !== "stopped") {
628
1005
  await ctx.runMutation(thread.onStatusChangeHandle as FunctionHandle<"mutation">, {
@@ -695,7 +1072,7 @@ export function createToolOutcomePart(
695
1072
  };
696
1073
 
697
1074
  if (pendingPart?.callProviderMetadata != null) {
698
- part.callProviderMetadata = pendingPart.callProviderMetadata as any;
1075
+ part.callProviderMetadata = pendingPart.callProviderMetadata as ProviderMetadata;
699
1076
  }
700
1077
  return part;
701
1078
  }