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
@@ -1,11 +1,13 @@
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";
8
9
  import { enqueueAction } from "./agent.js";
10
+ import { isAlive } from "./streams.js";
9
11
 
10
12
  const logger = new Logger("tool_calls");
11
13
  const SECOND = 1000;
@@ -13,6 +15,72 @@ const MINUTE = 60 * SECOND;
13
15
  const TOOL_CALL_TIMEOUT_MS = 30 * MINUTE;
14
16
  const ASYNC_CALLBACK_MAX_ATTEMPTS = 3;
15
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
+ }
16
84
 
17
85
  function normalizeToolCallTimeoutMs(timeoutMs: number): number {
18
86
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
@@ -43,16 +111,29 @@ async function cleanupTimeoutFn(ctx: MutationCtx, toolCall: Doc<"tool_calls">):
43
111
  }
44
112
  }
45
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
+
46
124
  async function failToolCallIfPending(ctx: MutationCtx, toolCall: Doc<"tool_calls">, error: string): Promise<boolean> {
47
125
  const latest = await ctx.db.get(toolCall._id);
48
126
  if (!latest || latest.status !== "pending") {
49
127
  return false;
50
128
  }
51
129
  await cleanupTimeoutFn(ctx, latest);
130
+ await cleanupExecutionRetryFn(ctx, latest);
52
131
  await ctx.db.patch(latest._id, {
53
132
  error,
54
133
  status: "failed",
55
134
  callbackLastError: error,
135
+ executionRetryFnId: undefined,
136
+ nextRetryAt: undefined,
56
137
  });
57
138
 
58
139
  if (latest.saveDelta) {
@@ -95,6 +176,15 @@ export type ToolCallDoc = {
95
176
  args: unknown;
96
177
  result?: unknown;
97
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;
98
188
  };
99
189
 
100
190
  function publicToolCall(toolCall: Doc<"tool_calls">): ToolCallDoc {
@@ -108,6 +198,15 @@ function publicToolCall(toolCall: Doc<"tool_calls">): ToolCallDoc {
108
198
  args: toolCall.args,
109
199
  result: toolCall.result,
110
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,
111
210
  };
112
211
  }
113
212
 
@@ -122,8 +221,71 @@ export const vToolCallDoc = v.object({
122
221
  args: v.any(),
123
222
  result: v.optional(v.any()),
124
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()),
125
233
  });
126
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
+
127
289
  export const create = mutation({
128
290
  args: {
129
291
  threadId: v.id("threads"),
@@ -131,43 +293,25 @@ export const create = mutation({
131
293
  toolCallId: v.string(),
132
294
  toolName: v.string(),
133
295
  callback: v.optional(v.string()),
296
+ handler: v.optional(v.string()),
297
+ retry: v.optional(v.any()),
134
298
  args: v.any(),
135
299
  saveDelta: v.boolean(),
136
300
  },
137
301
  returns: vToolCallDoc,
138
302
  handler: async (ctx, args) => {
139
- const existingToolCall = await ctx.db
140
- .query("tool_calls")
141
- .withIndex("by_thread_tool_call_id", (q) => q.eq("threadId", args.threadId).eq("toolCallId", args.toolCallId))
142
- .first();
143
- if (existingToolCall) {
144
- throw new Error(`Tool call ${args.toolCallId} already exists`);
145
- }
146
- logger.debug(
147
- `create: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}, msgId=${args.msgId}`,
148
- );
149
- const expiresAt = Date.now() + TOOL_CALL_TIMEOUT_MS;
150
- const toolCallId = await ctx.db.insert("tool_calls", {
303
+ const toolCall = await createToolCallRecord(ctx, {
151
304
  threadId: args.threadId,
152
305
  msgId: args.msgId,
153
306
  toolCallId: args.toolCallId,
154
307
  toolName: args.toolName,
155
308
  callback: args.callback,
156
- callbackAttempt: args.callback ? 0 : undefined,
309
+ handler: args.handler,
310
+ retry: normalizeSyncToolRetryPolicy(args.retry),
157
311
  args: args.args,
158
312
  saveDelta: args.saveDelta,
159
- timeoutMs: TOOL_CALL_TIMEOUT_MS,
160
- expiresAt,
161
- status: "pending",
162
- });
163
- const timeoutFnId = await ctx.scheduler.runAfter(TOOL_CALL_TIMEOUT_MS, internal.tool_calls.failPendingToolCall, {
164
- threadId: args.threadId,
165
- toolCallId: args.toolCallId,
166
313
  });
167
- await ctx.db.patch(toolCallId, { timeoutFnId });
168
-
169
- const toolCall = await ctx.db.get(toolCallId);
170
- return publicToolCall(toolCall!);
314
+ return publicToolCall(toolCall);
171
315
  },
172
316
  });
173
317
 
@@ -188,7 +332,13 @@ export const setResult = mutation({
188
332
  }
189
333
  logger.debug(`setResult: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}`);
190
334
  await cleanupTimeoutFn(ctx, toolCall);
191
- 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
+ });
192
342
  return true;
193
343
  },
194
344
  });
@@ -210,7 +360,14 @@ export const setError = mutation({
210
360
  }
211
361
  logger.debug(`setError: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}, error=${args.error}`);
212
362
  await cleanupTimeoutFn(ctx, toolCall);
213
- 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
+ });
214
371
  return true;
215
372
  },
216
373
  });
@@ -319,6 +476,7 @@ export const scheduleToolCall = mutation({
319
476
  toolName: v.string(),
320
477
  args: v.any(),
321
478
  handler: v.string(),
479
+ retry: v.optional(v.any()),
322
480
  saveDelta: v.boolean(),
323
481
  },
324
482
  returns: v.null(),
@@ -332,11 +490,13 @@ export const scheduleToolCall = mutation({
332
490
  logger.debug(`scheduleToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
333
491
 
334
492
  // Create the tool call record
335
- await ctx.runMutation(api.tool_calls.create, {
493
+ await createToolCallRecord(ctx, {
336
494
  threadId: args.threadId,
337
495
  msgId: args.msgId,
338
496
  toolCallId: args.toolCallId,
339
497
  toolName: args.toolName,
498
+ handler: args.handler,
499
+ retry: normalizeSyncToolRetryPolicy(args.retry),
340
500
  args: args.args,
341
501
  saveDelta: args.saveDelta,
342
502
  });
@@ -381,7 +541,7 @@ export const scheduleAsyncToolCall = mutation({
381
541
  logger.debug(`scheduleAsyncToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
382
542
 
383
543
  // Create the tool call record (will remain pending until addToolResult is called)
384
- await ctx.runMutation(api.tool_calls.create, {
544
+ await createToolCallRecord(ctx, {
385
545
  threadId: args.threadId,
386
546
  toolCallId: args.toolCallId,
387
547
  msgId: args.msgId,
@@ -411,6 +571,139 @@ export const scheduleAsyncToolCall = mutation({
411
571
  },
412
572
  });
413
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
+
414
707
  /**
415
708
  * Execute a tool call
416
709
  */
@@ -422,8 +715,6 @@ export const executeToolCall = internalAction({
422
715
  },
423
716
  returns: v.null(),
424
717
  handler: async (ctx, args) => {
425
- logger.debug(`executeToolCall: callId=${args.toolCallId}, thread=${args.threadId}`);
426
-
427
718
  // Get the tool call record
428
719
  const toolCall = await ctx.runQuery(api.tool_calls.getByToolCallId, {
429
720
  threadId: args.threadId,
@@ -433,34 +724,120 @@ export const executeToolCall = internalAction({
433
724
  if (!toolCall) {
434
725
  throw new Error(`Tool call ${args.toolCallId} not found`);
435
726
  }
727
+ if (toolCall.status !== "pending") {
728
+ logger.debug(`executeToolCall: skipping callId=${args.toolCallId}, status already terminal (${toolCall.status})`);
729
+ return null;
730
+ }
436
731
  logger.debug(`executeToolCall: tool=${toolCall.toolName}`);
437
732
 
438
- try {
439
- // Execute the tool handler
440
- // The handler string is passed from the client and we need to resolve it
441
- // For now, we'll use ctx.runAction with a dynamic reference
442
- // This requires the handler to be a proper function reference string
443
- 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
+ }
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
+ );
444
764
 
765
+ try {
445
766
  logger.debug(`executeToolCall: invoking handler for callId=${args.toolCallId}`);
446
- 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>);
447
768
  logger.debug(`executeToolCall: handler succeeded for callId=${args.toolCallId}`);
448
769
  await ctx.runMutation(api.tool_calls.addToolResult, {
449
770
  threadId: args.threadId,
450
771
  result,
451
772
  toolCallId: toolCall.toolCallId,
452
773
  });
774
+ return null;
453
775
  } catch (e) {
454
- const error = e instanceof Error ? e.message : String(e);
455
- 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
+ });
456
834
  await ctx.runMutation(api.tool_calls.addToolError, {
457
835
  threadId: args.threadId,
458
836
  error,
459
837
  toolCallId: toolCall.toolCallId,
460
838
  });
839
+ return null;
461
840
  }
462
-
463
- return null;
464
841
  },
465
842
  });
466
843
 
@@ -622,6 +999,7 @@ export const onToolComplete = internalMutation({
622
999
  status: "stopped",
623
1000
  activeStream: null,
624
1001
  continue: false,
1002
+ retryState: undefined,
625
1003
  });
626
1004
  if (thread.onStatusChangeHandle && previousStatus !== "stopped") {
627
1005
  await ctx.runMutation(thread.onStatusChangeHandle as FunctionHandle<"mutation">, {
@@ -648,11 +1026,17 @@ export const onToolComplete = internalMutation({
648
1026
  });
649
1027
 
650
1028
  if (pending.length === 0) {
651
- logger.debug("onToolComplete: all tool calls complete, scheduling continueStream");
652
- // All tool calls complete - schedule continuation
653
- await ctx.scheduler.runAfter(0, api.agent.continueStream, {
654
- threadId: args.threadId,
655
- });
1029
+ const activeStream = thread.activeStream ? await ctx.db.get(thread.activeStream) : null;
1030
+ if (isAlive(activeStream)) {
1031
+ logger.debug("onToolComplete: all tool calls complete, active stream still alive, setting continue flag");
1032
+ await ctx.db.patch(args.threadId, { continue: true });
1033
+ } else {
1034
+ logger.debug("onToolComplete: all tool calls complete, scheduling continueStream");
1035
+ // All tool calls complete - schedule continuation
1036
+ await ctx.scheduler.runAfter(0, api.agent.continueStream, {
1037
+ threadId: args.threadId,
1038
+ });
1039
+ }
656
1040
  } else {
657
1041
  logger.debug(`onToolComplete: ${pending.length} tool calls still pending`);
658
1042
  }
@@ -688,7 +1072,7 @@ export function createToolOutcomePart(
688
1072
  };
689
1073
 
690
1074
  if (pendingPart?.callProviderMetadata != null) {
691
- part.callProviderMetadata = pendingPart.callProviderMetadata as any;
1075
+ part.callProviderMetadata = pendingPart.callProviderMetadata as ProviderMetadata;
692
1076
  }
693
1077
  return part;
694
1078
  }