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
@@ -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) {
@@ -34,6 +101,10 @@ function formatTimeoutMs(timeoutMs: number): string {
34
101
  return `${timeoutMs}ms`;
35
102
  }
36
103
 
104
+ export function shouldContinueAfterToolCompletion(status: Doc<"threads">["status"]): boolean {
105
+ return status === "streaming" || status === "awaiting_tool_results";
106
+ }
107
+
37
108
  async function cleanupTimeoutFn(ctx: MutationCtx, toolCall: Doc<"tool_calls">): Promise<void> {
38
109
  if (!toolCall.timeoutFnId) {
39
110
  return;
@@ -44,16 +115,29 @@ async function cleanupTimeoutFn(ctx: MutationCtx, toolCall: Doc<"tool_calls">):
44
115
  }
45
116
  }
46
117
 
118
+ async function cleanupExecutionRetryFn(ctx: MutationCtx, toolCall: Doc<"tool_calls">): Promise<void> {
119
+ if (!toolCall.executionRetryFnId) {
120
+ return;
121
+ }
122
+ const retryFn = await ctx.db.system.get(toolCall.executionRetryFnId);
123
+ if (retryFn?.state.kind === "pending") {
124
+ await ctx.scheduler.cancel(toolCall.executionRetryFnId);
125
+ }
126
+ }
127
+
47
128
  async function failToolCallIfPending(ctx: MutationCtx, toolCall: Doc<"tool_calls">, error: string): Promise<boolean> {
48
129
  const latest = await ctx.db.get(toolCall._id);
49
130
  if (!latest || latest.status !== "pending") {
50
131
  return false;
51
132
  }
52
133
  await cleanupTimeoutFn(ctx, latest);
134
+ await cleanupExecutionRetryFn(ctx, latest);
53
135
  await ctx.db.patch(latest._id, {
54
136
  error,
55
137
  status: "failed",
56
138
  callbackLastError: error,
139
+ executionRetryFnId: undefined,
140
+ nextRetryAt: undefined,
57
141
  });
58
142
 
59
143
  if (latest.saveDelta) {
@@ -96,6 +180,15 @@ export type ToolCallDoc = {
96
180
  args: unknown;
97
181
  result?: unknown;
98
182
  error?: string;
183
+ status: "pending" | "completed" | "failed";
184
+ callbackAttempt?: number;
185
+ callbackLastError?: string;
186
+ handler?: string;
187
+ executionAttempt?: number;
188
+ executionMaxAttempts?: number;
189
+ executionLastError?: string;
190
+ executionRetryPolicy?: unknown;
191
+ nextRetryAt?: number;
99
192
  };
100
193
 
101
194
  function publicToolCall(toolCall: Doc<"tool_calls">): ToolCallDoc {
@@ -109,6 +202,15 @@ function publicToolCall(toolCall: Doc<"tool_calls">): ToolCallDoc {
109
202
  args: toolCall.args,
110
203
  result: toolCall.result,
111
204
  error: toolCall.error,
205
+ status: toolCall.status,
206
+ callbackAttempt: toolCall.callbackAttempt,
207
+ callbackLastError: toolCall.callbackLastError,
208
+ handler: toolCall.handler,
209
+ executionAttempt: toolCall.executionAttempt,
210
+ executionMaxAttempts: toolCall.executionMaxAttempts,
211
+ executionLastError: toolCall.executionLastError,
212
+ executionRetryPolicy: toolCall.executionRetryPolicy,
213
+ nextRetryAt: toolCall.nextRetryAt,
112
214
  };
113
215
  }
114
216
 
@@ -123,8 +225,71 @@ export const vToolCallDoc = v.object({
123
225
  args: v.any(),
124
226
  result: v.optional(v.any()),
125
227
  error: v.optional(v.string()),
228
+ status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed")),
229
+ callbackAttempt: v.optional(v.number()),
230
+ callbackLastError: v.optional(v.string()),
231
+ handler: v.optional(v.string()),
232
+ executionAttempt: v.optional(v.number()),
233
+ executionMaxAttempts: v.optional(v.number()),
234
+ executionLastError: v.optional(v.string()),
235
+ executionRetryPolicy: v.optional(v.any()),
236
+ nextRetryAt: v.optional(v.number()),
126
237
  });
127
238
 
239
+ type CreateToolCallArgs = {
240
+ threadId: Id<"threads">;
241
+ msgId: string;
242
+ toolCallId: string;
243
+ toolName: string;
244
+ callback?: string;
245
+ handler?: string;
246
+ retry?: SyncToolRetryPolicy;
247
+ args: unknown;
248
+ saveDelta: boolean;
249
+ };
250
+
251
+ async function createToolCallRecord(ctx: MutationCtx, args: CreateToolCallArgs): Promise<Doc<"tool_calls">> {
252
+ const existingToolCall = await ctx.db
253
+ .query("tool_calls")
254
+ .withIndex("by_thread_tool_call_id", (q) => q.eq("threadId", args.threadId).eq("toolCallId", args.toolCallId))
255
+ .first();
256
+ if (existingToolCall) {
257
+ throw new Error(`Tool call ${args.toolCallId} already exists`);
258
+ }
259
+ logger.debug(
260
+ `create: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}, msgId=${args.msgId}`,
261
+ );
262
+ const expiresAt = Date.now() + TOOL_CALL_TIMEOUT_MS;
263
+ const toolCallId = await ctx.db.insert("tool_calls", {
264
+ threadId: args.threadId,
265
+ msgId: args.msgId,
266
+ toolCallId: args.toolCallId,
267
+ toolName: args.toolName,
268
+ callback: args.callback,
269
+ handler: args.handler,
270
+ callbackAttempt: args.callback ? 0 : undefined,
271
+ executionAttempt: args.retry ? 0 : undefined,
272
+ executionMaxAttempts: args.retry?.maxAttempts,
273
+ executionRetryPolicy: args.retry,
274
+ args: args.args,
275
+ saveDelta: args.saveDelta,
276
+ timeoutMs: TOOL_CALL_TIMEOUT_MS,
277
+ expiresAt,
278
+ status: "pending",
279
+ });
280
+ const timeoutFnId = await ctx.scheduler.runAfter(TOOL_CALL_TIMEOUT_MS, internal.tool_calls.failPendingToolCall, {
281
+ threadId: args.threadId,
282
+ toolCallId: args.toolCallId,
283
+ });
284
+ await ctx.db.patch(toolCallId, { timeoutFnId });
285
+
286
+ const toolCall = await ctx.db.get(toolCallId);
287
+ if (!toolCall) {
288
+ throw new Error(`Tool call ${toolCallId} not found after creation`);
289
+ }
290
+ return toolCall;
291
+ }
292
+
128
293
  export const create = mutation({
129
294
  args: {
130
295
  threadId: v.id("threads"),
@@ -132,43 +297,25 @@ export const create = mutation({
132
297
  toolCallId: v.string(),
133
298
  toolName: v.string(),
134
299
  callback: v.optional(v.string()),
300
+ handler: v.optional(v.string()),
301
+ retry: v.optional(v.any()),
135
302
  args: v.any(),
136
303
  saveDelta: v.boolean(),
137
304
  },
138
305
  returns: vToolCallDoc,
139
306
  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", {
307
+ const toolCall = await createToolCallRecord(ctx, {
152
308
  threadId: args.threadId,
153
309
  msgId: args.msgId,
154
310
  toolCallId: args.toolCallId,
155
311
  toolName: args.toolName,
156
312
  callback: args.callback,
157
- callbackAttempt: args.callback ? 0 : undefined,
313
+ handler: args.handler,
314
+ retry: normalizeSyncToolRetryPolicy(args.retry),
158
315
  args: args.args,
159
316
  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
317
  });
168
- await ctx.db.patch(toolCallId, { timeoutFnId });
169
-
170
- const toolCall = await ctx.db.get(toolCallId);
171
- return publicToolCall(toolCall!);
318
+ return publicToolCall(toolCall);
172
319
  },
173
320
  });
174
321
 
@@ -189,7 +336,13 @@ export const setResult = mutation({
189
336
  }
190
337
  logger.debug(`setResult: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}`);
191
338
  await cleanupTimeoutFn(ctx, toolCall);
192
- await ctx.db.patch(args.id, { result: args.result, status: "completed" });
339
+ await cleanupExecutionRetryFn(ctx, toolCall);
340
+ await ctx.db.patch(args.id, {
341
+ result: args.result,
342
+ status: "completed",
343
+ executionRetryFnId: undefined,
344
+ nextRetryAt: undefined,
345
+ });
193
346
  return true;
194
347
  },
195
348
  });
@@ -211,7 +364,14 @@ export const setError = mutation({
211
364
  }
212
365
  logger.debug(`setError: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}, error=${args.error}`);
213
366
  await cleanupTimeoutFn(ctx, toolCall);
214
- await ctx.db.patch(args.id, { error: args.error, status: "failed", callbackLastError: args.error });
367
+ await cleanupExecutionRetryFn(ctx, toolCall);
368
+ await ctx.db.patch(args.id, {
369
+ error: args.error,
370
+ status: "failed",
371
+ callbackLastError: args.error,
372
+ executionRetryFnId: undefined,
373
+ nextRetryAt: undefined,
374
+ });
215
375
  return true;
216
376
  },
217
377
  });
@@ -320,6 +480,7 @@ export const scheduleToolCall = mutation({
320
480
  toolName: v.string(),
321
481
  args: v.any(),
322
482
  handler: v.string(),
483
+ retry: v.optional(v.any()),
323
484
  saveDelta: v.boolean(),
324
485
  },
325
486
  returns: v.null(),
@@ -333,11 +494,13 @@ export const scheduleToolCall = mutation({
333
494
  logger.debug(`scheduleToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
334
495
 
335
496
  // Create the tool call record
336
- await ctx.runMutation(api.tool_calls.create, {
497
+ await createToolCallRecord(ctx, {
337
498
  threadId: args.threadId,
338
499
  msgId: args.msgId,
339
500
  toolCallId: args.toolCallId,
340
501
  toolName: args.toolName,
502
+ handler: args.handler,
503
+ retry: normalizeSyncToolRetryPolicy(args.retry),
341
504
  args: args.args,
342
505
  saveDelta: args.saveDelta,
343
506
  });
@@ -382,7 +545,7 @@ export const scheduleAsyncToolCall = mutation({
382
545
  logger.debug(`scheduleAsyncToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
383
546
 
384
547
  // Create the tool call record (will remain pending until addToolResult is called)
385
- await ctx.runMutation(api.tool_calls.create, {
548
+ await createToolCallRecord(ctx, {
386
549
  threadId: args.threadId,
387
550
  toolCallId: args.toolCallId,
388
551
  msgId: args.msgId,
@@ -412,6 +575,139 @@ export const scheduleAsyncToolCall = mutation({
412
575
  },
413
576
  });
414
577
 
578
+ export const updateExecutionRetryState = internalMutation({
579
+ args: {
580
+ threadId: v.id("threads"),
581
+ toolCallId: v.string(),
582
+ executionAttempt: v.number(),
583
+ executionLastError: v.optional(v.string()),
584
+ nextRetryAt: v.optional(v.number()),
585
+ executionRetryFnId: v.optional(v.id("_scheduled_functions")),
586
+ clearNextRetryAt: v.optional(v.boolean()),
587
+ clearExecutionRetryFnId: v.optional(v.boolean()),
588
+ },
589
+ returns: v.null(),
590
+ handler: async (ctx, args) => {
591
+ const toolCall = await getToolCallByScope(ctx, {
592
+ threadId: args.threadId,
593
+ toolCallId: args.toolCallId,
594
+ });
595
+ if (!toolCall || toolCall.status !== "pending") {
596
+ return null;
597
+ }
598
+
599
+ const patch: {
600
+ executionAttempt: number;
601
+ executionLastError?: string;
602
+ nextRetryAt?: number | undefined;
603
+ executionRetryFnId?: Id<"_scheduled_functions"> | undefined;
604
+ } = {
605
+ executionAttempt: args.executionAttempt,
606
+ };
607
+ if (args.executionLastError !== undefined) {
608
+ patch.executionLastError = args.executionLastError;
609
+ }
610
+ if (args.nextRetryAt !== undefined) {
611
+ patch.nextRetryAt = args.nextRetryAt;
612
+ }
613
+ if (args.executionRetryFnId !== undefined) {
614
+ patch.executionRetryFnId = args.executionRetryFnId;
615
+ }
616
+ if (args.clearNextRetryAt) {
617
+ patch.nextRetryAt = undefined;
618
+ }
619
+ if (args.clearExecutionRetryFnId) {
620
+ patch.executionRetryFnId = undefined;
621
+ }
622
+ await ctx.db.patch(toolCall._id, patch);
623
+ return null;
624
+ },
625
+ });
626
+
627
+ export const scheduleExecutionRetry = internalMutation({
628
+ args: {
629
+ threadId: v.id("threads"),
630
+ toolCallId: v.string(),
631
+ handler: v.string(),
632
+ executionAttempt: v.number(),
633
+ executionLastError: v.string(),
634
+ nextRetryAt: v.number(),
635
+ },
636
+ returns: v.null(),
637
+ handler: async (ctx, args) => {
638
+ const toolCall = await getToolCallByScope(ctx, {
639
+ threadId: args.threadId,
640
+ toolCallId: args.toolCallId,
641
+ });
642
+ if (!toolCall || toolCall.status !== "pending") {
643
+ return null;
644
+ }
645
+
646
+ await cleanupExecutionRetryFn(ctx, toolCall);
647
+ const delayMs = Math.max(0, args.nextRetryAt - Date.now());
648
+ const executionRetryFnId = await ctx.scheduler.runAfter(delayMs, internal.tool_calls.executeToolCall, {
649
+ threadId: args.threadId,
650
+ toolCallId: args.toolCallId,
651
+ handler: args.handler,
652
+ });
653
+
654
+ await ctx.db.patch(toolCall._id, {
655
+ executionAttempt: args.executionAttempt,
656
+ executionLastError: args.executionLastError,
657
+ nextRetryAt: args.nextRetryAt,
658
+ executionRetryFnId,
659
+ });
660
+ return null;
661
+ },
662
+ });
663
+
664
+ export const resumePendingSyncToolExecutions = mutation({
665
+ args: {
666
+ limit: v.optional(v.number()),
667
+ },
668
+ returns: v.number(),
669
+ handler: async (ctx, args) => {
670
+ const limit = Math.max(1, Math.floor(args.limit ?? 100));
671
+ const pending = await ctx.db
672
+ .query("tool_calls")
673
+ .withIndex("by_status_only", (q) => q.eq("status", "pending"))
674
+ .take(limit * 2);
675
+
676
+ let resumed = 0;
677
+ const now = Date.now();
678
+ for (const toolCall of pending) {
679
+ if (resumed >= limit) {
680
+ break;
681
+ }
682
+ if (!toolCall.handler) {
683
+ continue;
684
+ }
685
+ if (toolCall.executionRetryFnId) {
686
+ const retryFn = await ctx.db.system.get(toolCall.executionRetryFnId);
687
+ if (retryFn?.state.kind === "pending") {
688
+ continue;
689
+ }
690
+ }
691
+ const nextRetryAt = toolCall.nextRetryAt ?? now;
692
+ const delayMs = Math.max(0, nextRetryAt - now);
693
+ const executionRetryFnId = await ctx.scheduler.runAfter(delayMs, internal.tool_calls.executeToolCall, {
694
+ threadId: toolCall.threadId,
695
+ toolCallId: toolCall.toolCallId,
696
+ handler: toolCall.handler,
697
+ });
698
+ await ctx.db.patch(toolCall._id, {
699
+ executionRetryFnId,
700
+ });
701
+ resumed += 1;
702
+ }
703
+
704
+ if (resumed > 0) {
705
+ logger.warn(`resumePendingSyncToolExecutions: resumed ${resumed} pending sync tool call(s)`);
706
+ }
707
+ return resumed;
708
+ },
709
+ });
710
+
415
711
  /**
416
712
  * Execute a tool call
417
713
  */
@@ -423,8 +719,6 @@ export const executeToolCall = internalAction({
423
719
  },
424
720
  returns: v.null(),
425
721
  handler: async (ctx, args) => {
426
- logger.debug(`executeToolCall: callId=${args.toolCallId}, thread=${args.threadId}`);
427
-
428
722
  // Get the tool call record
429
723
  const toolCall = await ctx.runQuery(api.tool_calls.getByToolCallId, {
430
724
  threadId: args.threadId,
@@ -434,34 +728,120 @@ export const executeToolCall = internalAction({
434
728
  if (!toolCall) {
435
729
  throw new Error(`Tool call ${args.toolCallId} not found`);
436
730
  }
731
+ if (toolCall.status !== "pending") {
732
+ logger.debug(`executeToolCall: skipping callId=${args.toolCallId}, status already terminal (${toolCall.status})`);
733
+ return null;
734
+ }
437
735
  logger.debug(`executeToolCall: tool=${toolCall.toolName}`);
438
736
 
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 : {};
737
+ const thread = await ctx.runQuery(api.threads.get, {
738
+ threadId: args.threadId,
739
+ });
740
+ if (!thread) {
741
+ throw new Error(`Thread ${args.threadId} not found`);
742
+ }
743
+ if (thread.stopSignal || thread.status === "stopped") {
744
+ await ctx.runMutation(api.tool_calls.addToolError, {
745
+ threadId: args.threadId,
746
+ toolCallId: toolCall.toolCallId,
747
+ error: "Tool execution cancelled because the thread was stopped",
748
+ });
749
+ return null;
750
+ }
751
+
752
+ const handler = toolCall.handler ?? args.handler;
753
+ const retryPolicy = normalizeSyncToolRetryPolicy(toolCall.executionRetryPolicy);
754
+ const retryEnabled = retryPolicy?.enabled === true;
755
+ const maxAttempts = retryEnabled ? Math.max(1, retryPolicy.maxAttempts ?? SYNC_TOOL_MAX_ATTEMPTS) : 1;
756
+ const toolArgs = typeof toolCall.args === "object" && toolCall.args !== null ? toolCall.args : {};
757
+ const attempt = Math.max(1, (toolCall.executionAttempt ?? 0) + 1);
758
+ await ctx.runMutation(internal.tool_calls.updateExecutionRetryState, {
759
+ threadId: args.threadId,
760
+ toolCallId: args.toolCallId,
761
+ executionAttempt: attempt,
762
+ clearNextRetryAt: true,
763
+ clearExecutionRetryFnId: true,
764
+ });
765
+ logger.debug(
766
+ `executeToolCall: callId=${args.toolCallId}, thread=${args.threadId}, attempt=${attempt}/${maxAttempts}`,
767
+ );
445
768
 
769
+ try {
446
770
  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>);
771
+ const result = await ctx.runAction(handler as FunctionHandle<"action">, toolArgs as Record<string, unknown>);
448
772
  logger.debug(`executeToolCall: handler succeeded for callId=${args.toolCallId}`);
449
773
  await ctx.runMutation(api.tool_calls.addToolResult, {
450
774
  threadId: args.threadId,
451
775
  result,
452
776
  toolCallId: toolCall.toolCallId,
453
777
  });
778
+ return null;
454
779
  } catch (e) {
455
- const error = e instanceof Error ? e.message : String(e);
456
- logger.debug(`executeToolCall: handler failed for callId=${args.toolCallId}: ${error}`);
780
+ const errorInfo = extractToolErrorInfo(e);
781
+ const error = errorInfo.message;
782
+ logger.debug(`executeToolCall: handler failed for callId=${args.toolCallId} (attempt=${attempt}): ${error}`);
783
+
784
+ let retryable = false;
785
+ if (retryEnabled) {
786
+ if (retryPolicy.shouldRetryError) {
787
+ try {
788
+ const decision = await ctx.runAction(retryPolicy.shouldRetryError as FunctionHandle<"action">, {
789
+ threadId: args.threadId,
790
+ toolCallId: args.toolCallId,
791
+ toolName: toolCall.toolName,
792
+ args: toolCall.args,
793
+ error,
794
+ attempt,
795
+ maxAttempts,
796
+ });
797
+ retryable = isRetryableDecision(decision);
798
+ } catch (classifierError) {
799
+ logger.warn(
800
+ `executeToolCall: shouldRetryError failed for callId=${args.toolCallId}, falling back to default classifier: ${
801
+ classifierError instanceof Error ? classifierError.message : String(classifierError)
802
+ }`,
803
+ );
804
+ retryable = isRetryableToolErrorDefault(errorInfo);
805
+ }
806
+ } else {
807
+ retryable = isRetryableToolErrorDefault(errorInfo);
808
+ }
809
+ }
810
+
811
+ if (retryEnabled && retryable && attempt < maxAttempts) {
812
+ const delayMs = computeRetryDelayMs(attempt, retryPolicy.backoff);
813
+ const nextRetryAt = Date.now() + delayMs;
814
+ await ctx.runMutation(internal.tool_calls.scheduleExecutionRetry, {
815
+ threadId: args.threadId,
816
+ toolCallId: args.toolCallId,
817
+ handler,
818
+ executionAttempt: attempt,
819
+ executionLastError: error,
820
+ nextRetryAt,
821
+ });
822
+ logger.warn(
823
+ `executeToolCall: scheduled retry for callId=${args.toolCallId} in ${delayMs}ms (attempt ${
824
+ attempt + 1
825
+ }/${maxAttempts})`,
826
+ );
827
+ return null;
828
+ }
829
+
830
+ await ctx.runMutation(internal.tool_calls.updateExecutionRetryState, {
831
+ threadId: args.threadId,
832
+ toolCallId: args.toolCallId,
833
+ executionAttempt: attempt,
834
+ executionLastError: error,
835
+ clearNextRetryAt: true,
836
+ clearExecutionRetryFnId: true,
837
+ });
457
838
  await ctx.runMutation(api.tool_calls.addToolError, {
458
839
  threadId: args.threadId,
459
840
  error,
460
841
  toolCallId: toolCall.toolCallId,
461
842
  });
843
+ return null;
462
844
  }
463
-
464
- return null;
465
845
  },
466
846
  });
467
847
 
@@ -623,6 +1003,7 @@ export const onToolComplete = internalMutation({
623
1003
  status: "stopped",
624
1004
  activeStream: null,
625
1005
  continue: false,
1006
+ retryState: undefined,
626
1007
  });
627
1008
  if (thread.onStatusChangeHandle && previousStatus !== "stopped") {
628
1009
  await ctx.runMutation(thread.onStatusChangeHandle as FunctionHandle<"mutation">, {
@@ -643,6 +1024,11 @@ export const onToolComplete = internalMutation({
643
1024
  return null;
644
1025
  }
645
1026
 
1027
+ if (!shouldContinueAfterToolCompletion(thread.status)) {
1028
+ logger.debug(`onToolComplete: thread status=${thread.status}, skipping continuation`);
1029
+ return null;
1030
+ }
1031
+
646
1032
  // Check for pending tool calls
647
1033
  const pending = await ctx.runQuery(api.tool_calls.listPending, {
648
1034
  threadId: args.threadId,
@@ -695,7 +1081,7 @@ export function createToolOutcomePart(
695
1081
  };
696
1082
 
697
1083
  if (pendingPart?.callProviderMetadata != null) {
698
- part.callProviderMetadata = pendingPart.callProviderMetadata as any;
1084
+ part.callProviderMetadata = pendingPart.callProviderMetadata as ProviderMetadata;
699
1085
  }
700
1086
  return part;
701
1087
  }
@@ -0,0 +1,8 @@
1
+ import type { ModelMessage } from "ai";
2
+
3
+ export function endsWithAssistantMessage(messages: ModelMessage[]): boolean {
4
+ if (messages.length === 0) {
5
+ return false;
6
+ }
7
+ return messages[messages.length - 1]?.role === "assistant";
8
+ }