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