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