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,14 +1,61 @@
1
1
  import { v } from "convex/values";
2
- import { Logger } from "../logger.js";
2
+ import { Logger } from "../utils/logger.js";
3
+ import { extractToolErrorInfo, isRetryableDecision, isRetryableToolErrorDefault } from "../utils/retry.js";
3
4
  import { api, internal } from "./_generated/api.js";
4
5
  import { internalAction, internalMutation, mutation, query } from "./_generated/server.js";
5
6
  import { enqueueAction } from "./agent.js";
7
+ import { isAlive } from "./streams.js";
6
8
  const logger = new Logger("tool_calls");
7
9
  const SECOND = 1000;
8
10
  const MINUTE = 60 * SECOND;
9
11
  const TOOL_CALL_TIMEOUT_MS = 30 * MINUTE;
10
12
  const ASYNC_CALLBACK_MAX_ATTEMPTS = 3;
11
13
  const ASYNC_CALLBACK_RETRY_BASE_MS = 5 * SECOND;
14
+ const SYNC_TOOL_MAX_ATTEMPTS = 3;
15
+ const SYNC_TOOL_RETRY_INITIAL_BACKOFF_MS = 500;
16
+ function normalizeSyncToolRetryPolicy(value) {
17
+ if (value == null || typeof value !== "object") {
18
+ return undefined;
19
+ }
20
+ const obj = value;
21
+ if (obj.enabled !== true) {
22
+ return undefined;
23
+ }
24
+ return {
25
+ enabled: true,
26
+ maxAttempts: typeof obj.maxAttempts === "number" ? obj.maxAttempts : undefined,
27
+ backoff: obj.backoff ?? undefined,
28
+ shouldRetryError: typeof obj.shouldRetryError === "string" ? obj.shouldRetryError : undefined,
29
+ };
30
+ }
31
+ function clampDelayMs(value) {
32
+ if (!Number.isFinite(value) || value < 0)
33
+ return 0;
34
+ return Math.floor(value);
35
+ }
36
+ function computeRetryDelayMs(attempt, backoff) {
37
+ const policy = backoff ?? {
38
+ strategy: "exponential",
39
+ initialDelayMs: SYNC_TOOL_RETRY_INITIAL_BACKOFF_MS,
40
+ multiplier: 2,
41
+ maxDelayMs: 10_000,
42
+ jitter: true,
43
+ };
44
+ if ("delayMs" in policy) {
45
+ const delayMs = clampDelayMs(policy.delayMs);
46
+ if (!policy.jitter)
47
+ return delayMs;
48
+ return Math.floor(Math.random() * (delayMs + 1));
49
+ }
50
+ const initialDelayMs = clampDelayMs(policy.initialDelayMs);
51
+ const multiplier = Number.isFinite(policy.multiplier ?? 2) ? (policy.multiplier ?? 2) : 2;
52
+ const unbounded = initialDelayMs * multiplier ** Math.max(0, attempt - 1);
53
+ const maxDelayMs = policy.maxDelayMs == null ? unbounded : clampDelayMs(policy.maxDelayMs);
54
+ const delayMs = Math.min(unbounded, maxDelayMs);
55
+ if (!policy.jitter)
56
+ return delayMs;
57
+ return Math.floor(Math.random() * (delayMs + 1));
58
+ }
12
59
  function normalizeToolCallTimeoutMs(timeoutMs) {
13
60
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
14
61
  throw new Error(`Invalid tool call timeout: ${timeoutMs}`);
@@ -35,16 +82,28 @@ async function cleanupTimeoutFn(ctx, toolCall) {
35
82
  await ctx.scheduler.cancel(toolCall.timeoutFnId);
36
83
  }
37
84
  }
85
+ async function cleanupExecutionRetryFn(ctx, toolCall) {
86
+ if (!toolCall.executionRetryFnId) {
87
+ return;
88
+ }
89
+ const retryFn = await ctx.db.system.get(toolCall.executionRetryFnId);
90
+ if (retryFn?.state.kind === "pending") {
91
+ await ctx.scheduler.cancel(toolCall.executionRetryFnId);
92
+ }
93
+ }
38
94
  async function failToolCallIfPending(ctx, toolCall, error) {
39
95
  const latest = await ctx.db.get(toolCall._id);
40
96
  if (!latest || latest.status !== "pending") {
41
97
  return false;
42
98
  }
43
99
  await cleanupTimeoutFn(ctx, latest);
100
+ await cleanupExecutionRetryFn(ctx, latest);
44
101
  await ctx.db.patch(latest._id, {
45
102
  error,
46
103
  status: "failed",
47
104
  callbackLastError: error,
105
+ executionRetryFnId: undefined,
106
+ nextRetryAt: undefined,
48
107
  });
49
108
  if (latest.saveDelta) {
50
109
  logger.debug(`failPendingToolCall: inserting tool outcome delta for callId=${latest.toolCallId}`);
@@ -80,6 +139,15 @@ function publicToolCall(toolCall) {
80
139
  args: toolCall.args,
81
140
  result: toolCall.result,
82
141
  error: toolCall.error,
142
+ status: toolCall.status,
143
+ callbackAttempt: toolCall.callbackAttempt,
144
+ callbackLastError: toolCall.callbackLastError,
145
+ handler: toolCall.handler,
146
+ executionAttempt: toolCall.executionAttempt,
147
+ executionMaxAttempts: toolCall.executionMaxAttempts,
148
+ executionLastError: toolCall.executionLastError,
149
+ executionRetryPolicy: toolCall.executionRetryPolicy,
150
+ nextRetryAt: toolCall.nextRetryAt,
83
151
  };
84
152
  }
85
153
  // Tool call doc validator for return types
@@ -93,7 +161,54 @@ export const vToolCallDoc = v.object({
93
161
  args: v.any(),
94
162
  result: v.optional(v.any()),
95
163
  error: v.optional(v.string()),
164
+ status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed")),
165
+ callbackAttempt: v.optional(v.number()),
166
+ callbackLastError: v.optional(v.string()),
167
+ handler: v.optional(v.string()),
168
+ executionAttempt: v.optional(v.number()),
169
+ executionMaxAttempts: v.optional(v.number()),
170
+ executionLastError: v.optional(v.string()),
171
+ executionRetryPolicy: v.optional(v.any()),
172
+ nextRetryAt: v.optional(v.number()),
96
173
  });
174
+ async function createToolCallRecord(ctx, args) {
175
+ const existingToolCall = await ctx.db
176
+ .query("tool_calls")
177
+ .withIndex("by_thread_tool_call_id", (q) => q.eq("threadId", args.threadId).eq("toolCallId", args.toolCallId))
178
+ .first();
179
+ if (existingToolCall) {
180
+ throw new Error(`Tool call ${args.toolCallId} already exists`);
181
+ }
182
+ logger.debug(`create: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}, msgId=${args.msgId}`);
183
+ const expiresAt = Date.now() + TOOL_CALL_TIMEOUT_MS;
184
+ const toolCallId = await ctx.db.insert("tool_calls", {
185
+ threadId: args.threadId,
186
+ msgId: args.msgId,
187
+ toolCallId: args.toolCallId,
188
+ toolName: args.toolName,
189
+ callback: args.callback,
190
+ handler: args.handler,
191
+ callbackAttempt: args.callback ? 0 : undefined,
192
+ executionAttempt: args.retry ? 0 : undefined,
193
+ executionMaxAttempts: args.retry?.maxAttempts,
194
+ executionRetryPolicy: args.retry,
195
+ args: args.args,
196
+ saveDelta: args.saveDelta,
197
+ timeoutMs: TOOL_CALL_TIMEOUT_MS,
198
+ expiresAt,
199
+ status: "pending",
200
+ });
201
+ const timeoutFnId = await ctx.scheduler.runAfter(TOOL_CALL_TIMEOUT_MS, internal.tool_calls.failPendingToolCall, {
202
+ threadId: args.threadId,
203
+ toolCallId: args.toolCallId,
204
+ });
205
+ await ctx.db.patch(toolCallId, { timeoutFnId });
206
+ const toolCall = await ctx.db.get(toolCallId);
207
+ if (!toolCall) {
208
+ throw new Error(`Tool call ${toolCallId} not found after creation`);
209
+ }
210
+ return toolCall;
211
+ }
97
212
  export const create = mutation({
98
213
  args: {
99
214
  threadId: v.id("threads"),
@@ -101,39 +216,24 @@ export const create = mutation({
101
216
  toolCallId: v.string(),
102
217
  toolName: v.string(),
103
218
  callback: v.optional(v.string()),
219
+ handler: v.optional(v.string()),
220
+ retry: v.optional(v.any()),
104
221
  args: v.any(),
105
222
  saveDelta: v.boolean(),
106
223
  },
107
224
  returns: vToolCallDoc,
108
225
  handler: async (ctx, args) => {
109
- const existingToolCall = await ctx.db
110
- .query("tool_calls")
111
- .withIndex("by_thread_tool_call_id", (q) => q.eq("threadId", args.threadId).eq("toolCallId", args.toolCallId))
112
- .first();
113
- if (existingToolCall) {
114
- throw new Error(`Tool call ${args.toolCallId} already exists`);
115
- }
116
- logger.debug(`create: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}, msgId=${args.msgId}`);
117
- const expiresAt = Date.now() + TOOL_CALL_TIMEOUT_MS;
118
- const toolCallId = await ctx.db.insert("tool_calls", {
226
+ const toolCall = await createToolCallRecord(ctx, {
119
227
  threadId: args.threadId,
120
228
  msgId: args.msgId,
121
229
  toolCallId: args.toolCallId,
122
230
  toolName: args.toolName,
123
231
  callback: args.callback,
124
- callbackAttempt: args.callback ? 0 : undefined,
232
+ handler: args.handler,
233
+ retry: normalizeSyncToolRetryPolicy(args.retry),
125
234
  args: args.args,
126
235
  saveDelta: args.saveDelta,
127
- timeoutMs: TOOL_CALL_TIMEOUT_MS,
128
- expiresAt,
129
- status: "pending",
130
236
  });
131
- const timeoutFnId = await ctx.scheduler.runAfter(TOOL_CALL_TIMEOUT_MS, internal.tool_calls.failPendingToolCall, {
132
- threadId: args.threadId,
133
- toolCallId: args.toolCallId,
134
- });
135
- await ctx.db.patch(toolCallId, { timeoutFnId });
136
- const toolCall = await ctx.db.get(toolCallId);
137
237
  return publicToolCall(toolCall);
138
238
  },
139
239
  });
@@ -154,7 +254,13 @@ export const setResult = mutation({
154
254
  }
155
255
  logger.debug(`setResult: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}`);
156
256
  await cleanupTimeoutFn(ctx, toolCall);
157
- await ctx.db.patch(args.id, { result: args.result, status: "completed" });
257
+ await cleanupExecutionRetryFn(ctx, toolCall);
258
+ await ctx.db.patch(args.id, {
259
+ result: args.result,
260
+ status: "completed",
261
+ executionRetryFnId: undefined,
262
+ nextRetryAt: undefined,
263
+ });
158
264
  return true;
159
265
  },
160
266
  });
@@ -175,7 +281,14 @@ export const setError = mutation({
175
281
  }
176
282
  logger.debug(`setError: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}, error=${args.error}`);
177
283
  await cleanupTimeoutFn(ctx, toolCall);
178
- await ctx.db.patch(args.id, { error: args.error, status: "failed", callbackLastError: args.error });
284
+ await cleanupExecutionRetryFn(ctx, toolCall);
285
+ await ctx.db.patch(args.id, {
286
+ error: args.error,
287
+ status: "failed",
288
+ callbackLastError: args.error,
289
+ executionRetryFnId: undefined,
290
+ nextRetryAt: undefined,
291
+ });
179
292
  return true;
180
293
  },
181
294
  });
@@ -275,6 +388,7 @@ export const scheduleToolCall = mutation({
275
388
  toolName: v.string(),
276
389
  args: v.any(),
277
390
  handler: v.string(),
391
+ retry: v.optional(v.any()),
278
392
  saveDelta: v.boolean(),
279
393
  },
280
394
  returns: v.null(),
@@ -286,11 +400,13 @@ export const scheduleToolCall = mutation({
286
400
  }
287
401
  logger.debug(`scheduleToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
288
402
  // Create the tool call record
289
- await ctx.runMutation(api.tool_calls.create, {
403
+ await createToolCallRecord(ctx, {
290
404
  threadId: args.threadId,
291
405
  msgId: args.msgId,
292
406
  toolCallId: args.toolCallId,
293
407
  toolName: args.toolName,
408
+ handler: args.handler,
409
+ retry: normalizeSyncToolRetryPolicy(args.retry),
294
410
  args: args.args,
295
411
  saveDelta: args.saveDelta,
296
412
  });
@@ -329,7 +445,7 @@ export const scheduleAsyncToolCall = mutation({
329
445
  }
330
446
  logger.debug(`scheduleAsyncToolCall: tool=${args.toolName}, callId=${args.toolCallId}, thread=${args.threadId}`);
331
447
  // Create the tool call record (will remain pending until addToolResult is called)
332
- await ctx.runMutation(api.tool_calls.create, {
448
+ await createToolCallRecord(ctx, {
333
449
  threadId: args.threadId,
334
450
  toolCallId: args.toolCallId,
335
451
  msgId: args.msgId,
@@ -353,6 +469,126 @@ export const scheduleAsyncToolCall = mutation({
353
469
  return null;
354
470
  },
355
471
  });
472
+ export const updateExecutionRetryState = internalMutation({
473
+ args: {
474
+ threadId: v.id("threads"),
475
+ toolCallId: v.string(),
476
+ executionAttempt: v.number(),
477
+ executionLastError: v.optional(v.string()),
478
+ nextRetryAt: v.optional(v.number()),
479
+ executionRetryFnId: v.optional(v.id("_scheduled_functions")),
480
+ clearNextRetryAt: v.optional(v.boolean()),
481
+ clearExecutionRetryFnId: v.optional(v.boolean()),
482
+ },
483
+ returns: v.null(),
484
+ handler: async (ctx, args) => {
485
+ const toolCall = await getToolCallByScope(ctx, {
486
+ threadId: args.threadId,
487
+ toolCallId: args.toolCallId,
488
+ });
489
+ if (!toolCall || toolCall.status !== "pending") {
490
+ return null;
491
+ }
492
+ const patch = {
493
+ executionAttempt: args.executionAttempt,
494
+ };
495
+ if (args.executionLastError !== undefined) {
496
+ patch.executionLastError = args.executionLastError;
497
+ }
498
+ if (args.nextRetryAt !== undefined) {
499
+ patch.nextRetryAt = args.nextRetryAt;
500
+ }
501
+ if (args.executionRetryFnId !== undefined) {
502
+ patch.executionRetryFnId = args.executionRetryFnId;
503
+ }
504
+ if (args.clearNextRetryAt) {
505
+ patch.nextRetryAt = undefined;
506
+ }
507
+ if (args.clearExecutionRetryFnId) {
508
+ patch.executionRetryFnId = undefined;
509
+ }
510
+ await ctx.db.patch(toolCall._id, patch);
511
+ return null;
512
+ },
513
+ });
514
+ export const scheduleExecutionRetry = internalMutation({
515
+ args: {
516
+ threadId: v.id("threads"),
517
+ toolCallId: v.string(),
518
+ handler: v.string(),
519
+ executionAttempt: v.number(),
520
+ executionLastError: v.string(),
521
+ nextRetryAt: v.number(),
522
+ },
523
+ returns: v.null(),
524
+ handler: async (ctx, args) => {
525
+ const toolCall = await getToolCallByScope(ctx, {
526
+ threadId: args.threadId,
527
+ toolCallId: args.toolCallId,
528
+ });
529
+ if (!toolCall || toolCall.status !== "pending") {
530
+ return null;
531
+ }
532
+ await cleanupExecutionRetryFn(ctx, toolCall);
533
+ const delayMs = Math.max(0, args.nextRetryAt - Date.now());
534
+ const executionRetryFnId = await ctx.scheduler.runAfter(delayMs, internal.tool_calls.executeToolCall, {
535
+ threadId: args.threadId,
536
+ toolCallId: args.toolCallId,
537
+ handler: args.handler,
538
+ });
539
+ await ctx.db.patch(toolCall._id, {
540
+ executionAttempt: args.executionAttempt,
541
+ executionLastError: args.executionLastError,
542
+ nextRetryAt: args.nextRetryAt,
543
+ executionRetryFnId,
544
+ });
545
+ return null;
546
+ },
547
+ });
548
+ export const resumePendingSyncToolExecutions = mutation({
549
+ args: {
550
+ limit: v.optional(v.number()),
551
+ },
552
+ returns: v.number(),
553
+ handler: async (ctx, args) => {
554
+ const limit = Math.max(1, Math.floor(args.limit ?? 100));
555
+ const pending = await ctx.db
556
+ .query("tool_calls")
557
+ .withIndex("by_status_only", (q) => q.eq("status", "pending"))
558
+ .take(limit * 2);
559
+ let resumed = 0;
560
+ const now = Date.now();
561
+ for (const toolCall of pending) {
562
+ if (resumed >= limit) {
563
+ break;
564
+ }
565
+ if (!toolCall.handler) {
566
+ continue;
567
+ }
568
+ if (toolCall.executionRetryFnId) {
569
+ const retryFn = await ctx.db.system.get(toolCall.executionRetryFnId);
570
+ if (retryFn?.state.kind === "pending") {
571
+ continue;
572
+ }
573
+ }
574
+ const nextRetryAt = toolCall.nextRetryAt ?? now;
575
+ const delayMs = Math.max(0, nextRetryAt - now);
576
+ const executionRetryFnId = await ctx.scheduler.runAfter(delayMs, internal.tool_calls.executeToolCall, {
577
+ threadId: toolCall.threadId,
578
+ toolCallId: toolCall.toolCallId,
579
+ handler: toolCall.handler,
580
+ });
581
+ await ctx.db.patch(toolCall._id, {
582
+ executionRetryFnId,
583
+ });
584
+ resumed += 1;
585
+ }
586
+ if (resumed > 0) {
587
+ logger.warn(`resumePendingSyncToolExecutions: resumed ${resumed} pending sync tool call(s)`);
588
+ }
589
+ return resumed;
590
+ },
591
+ });
356
592
  /**
357
593
  * Execute a tool call
358
594
  */
@@ -364,7 +600,6 @@ export const executeToolCall = internalAction({
364
600
  },
365
601
  returns: v.null(),
366
602
  handler: async (ctx, args) => {
367
- logger.debug(`executeToolCall: callId=${args.toolCallId}, thread=${args.threadId}`);
368
603
  // Get the tool call record
369
604
  const toolCall = await ctx.runQuery(api.tool_calls.getByToolCallId, {
370
605
  threadId: args.threadId,
@@ -373,32 +608,107 @@ export const executeToolCall = internalAction({
373
608
  if (!toolCall) {
374
609
  throw new Error(`Tool call ${args.toolCallId} not found`);
375
610
  }
611
+ if (toolCall.status !== "pending") {
612
+ logger.debug(`executeToolCall: skipping callId=${args.toolCallId}, status already terminal (${toolCall.status})`);
613
+ return null;
614
+ }
376
615
  logger.debug(`executeToolCall: tool=${toolCall.toolName}`);
616
+ const thread = await ctx.runQuery(api.threads.get, {
617
+ threadId: args.threadId,
618
+ });
619
+ if (!thread) {
620
+ throw new Error(`Thread ${args.threadId} not found`);
621
+ }
622
+ if (thread.stopSignal || thread.status === "stopped") {
623
+ await ctx.runMutation(api.tool_calls.addToolError, {
624
+ threadId: args.threadId,
625
+ toolCallId: toolCall.toolCallId,
626
+ error: "Tool execution cancelled because the thread was stopped",
627
+ });
628
+ return null;
629
+ }
630
+ const handler = toolCall.handler ?? args.handler;
631
+ const retryPolicy = normalizeSyncToolRetryPolicy(toolCall.executionRetryPolicy);
632
+ const retryEnabled = retryPolicy?.enabled === true;
633
+ const maxAttempts = retryEnabled ? Math.max(1, retryPolicy.maxAttempts ?? SYNC_TOOL_MAX_ATTEMPTS) : 1;
634
+ const toolArgs = typeof toolCall.args === "object" && toolCall.args !== null ? toolCall.args : {};
635
+ const attempt = Math.max(1, (toolCall.executionAttempt ?? 0) + 1);
636
+ await ctx.runMutation(internal.tool_calls.updateExecutionRetryState, {
637
+ threadId: args.threadId,
638
+ toolCallId: args.toolCallId,
639
+ executionAttempt: attempt,
640
+ clearNextRetryAt: true,
641
+ clearExecutionRetryFnId: true,
642
+ });
643
+ logger.debug(`executeToolCall: callId=${args.toolCallId}, thread=${args.threadId}, attempt=${attempt}/${maxAttempts}`);
377
644
  try {
378
- // Execute the tool handler
379
- // The handler string is passed from the client and we need to resolve it
380
- // For now, we'll use ctx.runAction with a dynamic reference
381
- // This requires the handler to be a proper function reference string
382
- const toolArgs = typeof toolCall.args === "object" && toolCall.args !== null ? toolCall.args : {};
383
645
  logger.debug(`executeToolCall: invoking handler for callId=${args.toolCallId}`);
384
- const result = await ctx.runAction(args.handler, toolArgs);
646
+ const result = await ctx.runAction(handler, toolArgs);
385
647
  logger.debug(`executeToolCall: handler succeeded for callId=${args.toolCallId}`);
386
648
  await ctx.runMutation(api.tool_calls.addToolResult, {
387
649
  threadId: args.threadId,
388
650
  result,
389
651
  toolCallId: toolCall.toolCallId,
390
652
  });
653
+ return null;
391
654
  }
392
655
  catch (e) {
393
- const error = e instanceof Error ? e.message : String(e);
394
- logger.debug(`executeToolCall: handler failed for callId=${args.toolCallId}: ${error}`);
656
+ const errorInfo = extractToolErrorInfo(e);
657
+ const error = errorInfo.message;
658
+ logger.debug(`executeToolCall: handler failed for callId=${args.toolCallId} (attempt=${attempt}): ${error}`);
659
+ let retryable = false;
660
+ if (retryEnabled) {
661
+ if (retryPolicy.shouldRetryError) {
662
+ try {
663
+ const decision = await ctx.runAction(retryPolicy.shouldRetryError, {
664
+ threadId: args.threadId,
665
+ toolCallId: args.toolCallId,
666
+ toolName: toolCall.toolName,
667
+ args: toolCall.args,
668
+ error,
669
+ attempt,
670
+ maxAttempts,
671
+ });
672
+ retryable = isRetryableDecision(decision);
673
+ }
674
+ catch (classifierError) {
675
+ logger.warn(`executeToolCall: shouldRetryError failed for callId=${args.toolCallId}, falling back to default classifier: ${classifierError instanceof Error ? classifierError.message : String(classifierError)}`);
676
+ retryable = isRetryableToolErrorDefault(errorInfo);
677
+ }
678
+ }
679
+ else {
680
+ retryable = isRetryableToolErrorDefault(errorInfo);
681
+ }
682
+ }
683
+ if (retryEnabled && retryable && attempt < maxAttempts) {
684
+ const delayMs = computeRetryDelayMs(attempt, retryPolicy.backoff);
685
+ const nextRetryAt = Date.now() + delayMs;
686
+ await ctx.runMutation(internal.tool_calls.scheduleExecutionRetry, {
687
+ threadId: args.threadId,
688
+ toolCallId: args.toolCallId,
689
+ handler,
690
+ executionAttempt: attempt,
691
+ executionLastError: error,
692
+ nextRetryAt,
693
+ });
694
+ logger.warn(`executeToolCall: scheduled retry for callId=${args.toolCallId} in ${delayMs}ms (attempt ${attempt + 1}/${maxAttempts})`);
695
+ return null;
696
+ }
697
+ await ctx.runMutation(internal.tool_calls.updateExecutionRetryState, {
698
+ threadId: args.threadId,
699
+ toolCallId: args.toolCallId,
700
+ executionAttempt: attempt,
701
+ executionLastError: error,
702
+ clearNextRetryAt: true,
703
+ clearExecutionRetryFnId: true,
704
+ });
395
705
  await ctx.runMutation(api.tool_calls.addToolError, {
396
706
  threadId: args.threadId,
397
707
  error,
398
708
  toolCallId: toolCall.toolCallId,
399
709
  });
710
+ return null;
400
711
  }
401
- return null;
402
712
  },
403
713
  });
404
714
  /**
@@ -542,6 +852,7 @@ export const onToolComplete = internalMutation({
542
852
  status: "stopped",
543
853
  activeStream: null,
544
854
  continue: false,
855
+ retryState: undefined,
545
856
  });
546
857
  if (thread.onStatusChangeHandle && previousStatus !== "stopped") {
547
858
  await ctx.runMutation(thread.onStatusChangeHandle, {
@@ -566,11 +877,18 @@ export const onToolComplete = internalMutation({
566
877
  threadId: args.threadId,
567
878
  });
568
879
  if (pending.length === 0) {
569
- logger.debug("onToolComplete: all tool calls complete, scheduling continueStream");
570
- // All tool calls complete - schedule continuation
571
- await ctx.scheduler.runAfter(0, api.agent.continueStream, {
572
- threadId: args.threadId,
573
- });
880
+ const activeStream = thread.activeStream ? await ctx.db.get(thread.activeStream) : null;
881
+ if (isAlive(activeStream)) {
882
+ logger.debug("onToolComplete: all tool calls complete, active stream still alive, setting continue flag");
883
+ await ctx.db.patch(args.threadId, { continue: true });
884
+ }
885
+ else {
886
+ logger.debug("onToolComplete: all tool calls complete, scheduling continueStream");
887
+ // All tool calls complete - schedule continuation
888
+ await ctx.scheduler.runAfter(0, api.agent.continueStream, {
889
+ threadId: args.threadId,
890
+ });
891
+ }
574
892
  }
575
893
  else {
576
894
  logger.debug(`onToolComplete: ${pending.length} tool calls still pending`);