+ );
+}
diff --git a/example/ui/main.tsx b/example/ui/main.tsx
index 949b74b0..fb076f42 100644
--- a/example/ui/main.tsx
+++ b/example/ui/main.tsx
@@ -5,6 +5,7 @@ import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import { Toaster } from "./components/ui/toaster";
import ChatBasic from "./chat/ChatBasic";
import ChatStreaming from "./chat/ChatStreaming";
+import ChatApproval from "./chat/ChatApproval";
import FilesImages from "./files/FilesImages";
import RateLimiting from "./rate_limiting/RateLimiting";
import { WeatherFashion } from "./workflows/WeatherFashion";
@@ -41,6 +42,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -88,6 +90,20 @@ function Index() {
streaming!).
+
+
+ Tool Approval
+
+
+ Demonstrates the AI SDK v6 tool approval workflow. Tools can
+ require user approval before execution, enabling human-in-the-loop
+ patterns for sensitive operations like file deletion or money
+ transfers.
+
+
["parts"] = [];
@@ -477,40 +523,13 @@ function createAssistantUIMessage<
break;
}
case "tool-result": {
+ // Note: execution-denied outputs are handled separately via pre-extraction
+ // from raw content (before toModelMessage converts them to text format).
+ // See executionDeniedResults processing at the end of this function.
const typedPart = contentPart as unknown as ToolResultPart & {
output: { type: string; value?: unknown; reason?: string };
};
- // Check if this is an execution-denied result
- if (typedPart.output?.type === "execution-denied") {
- const call = allParts.find(
- (part) =>
- part.type === `tool-${contentPart.toolName}` &&
- "toolCallId" in part &&
- part.toolCallId === contentPart.toolCallId,
- ) as ToolUIPart | undefined;
-
- if (call) {
- call.state = "output-denied";
- if (!("approval" in call) || !call.approval) {
- (call as ToolUIPart & { approval?: object }).approval = {
- id: "",
- approved: false,
- reason: typedPart.output.reason,
- };
- } else {
- const approval = (
- call as ToolUIPart & {
- approval: { approved?: boolean; reason?: string };
- }
- ).approval;
- approval.approved = false;
- approval.reason = typedPart.output.reason;
- }
- }
- break;
- }
-
const output =
typeof typedPart.output?.type === "string"
? typedPart.output.value
@@ -563,68 +582,6 @@ function createAssistantUIMessage<
}
break;
}
- case "tool-approval-request": {
- // Find the matching tool call
- const typedPart = contentPart as {
- toolCallId: string;
- approvalId: string;
- };
- const toolCallPart = allParts.find(
- (part) =>
- "toolCallId" in part && part.toolCallId === typedPart.toolCallId,
- ) as ToolUIPart | undefined;
-
- if (toolCallPart) {
- toolCallPart.state = "approval-requested";
- (toolCallPart as ToolUIPart & { approval?: object }).approval = {
- id: typedPart.approvalId,
- };
- } else {
- console.warn(
- "Tool approval request without preceding tool call",
- contentPart,
- );
- }
- break;
- }
- case "tool-approval-response": {
- // Find the tool call that has this approval by matching approval.id
- const typedPart = contentPart as {
- approvalId: string;
- approved: boolean;
- reason?: string;
- };
- const toolCallPart = allParts.find(
- (part) =>
- "approval" in part &&
- (part as ToolUIPart & { approval?: { id: string } }).approval
- ?.id === typedPart.approvalId,
- ) as ToolUIPart | undefined;
-
- if (toolCallPart) {
- if (typedPart.approved) {
- toolCallPart.state = "approval-responded";
- (toolCallPart as ToolUIPart & { approval?: object }).approval = {
- id: typedPart.approvalId,
- approved: true,
- reason: typedPart.reason,
- };
- } else {
- toolCallPart.state = "output-denied";
- (toolCallPart as ToolUIPart & { approval?: object }).approval = {
- id: typedPart.approvalId,
- approved: false,
- reason: typedPart.reason,
- };
- }
- } else {
- console.warn(
- "Tool approval response without matching approval request",
- contentPart,
- );
- }
- break;
- }
default: {
const maybeSource = contentPart as unknown as SourcePart;
if (maybeSource.type === "source") {
@@ -645,6 +602,85 @@ function createAssistantUIMessage<
}
}
+ // Final output states that should not be overwritten by approval processing
+ const finalStates = new Set([
+ "output-available",
+ "output-error",
+ "output-denied",
+ ]);
+
+ // Process pre-extracted approval parts (extracted before toModelMessage filtered them)
+ for (const approvalPart of approvalParts) {
+ if (approvalPart.type === "tool-approval-request") {
+ const toolCallPart = allParts.find(
+ (part) =>
+ "toolCallId" in part && part.toolCallId === approvalPart.toolCallId,
+ ) as ToolUIPart | undefined;
+
+ if (toolCallPart) {
+ // Always set approval info (needed for response matching), but only
+ // update state if not in a final state
+ (toolCallPart as ToolUIPart & { approval?: object }).approval = {
+ id: approvalPart.approvalId,
+ };
+ if (!finalStates.has(toolCallPart.state)) {
+ toolCallPart.state = "approval-requested";
+ }
+ }
+ } else if (approvalPart.type === "tool-approval-response") {
+ const toolCallPart = allParts.find(
+ (part) =>
+ "approval" in part &&
+ (part as ToolUIPart & { approval?: { id: string } }).approval?.id ===
+ approvalPart.approvalId,
+ ) as ToolUIPart | undefined;
+
+ if (toolCallPart) {
+ // Always update approval info, but only update state if not in a final state
+ (toolCallPart as ToolUIPart & { approval?: object }).approval = {
+ id: approvalPart.approvalId,
+ approved: approvalPart.approved,
+ reason: approvalPart.reason,
+ };
+ if (!finalStates.has(toolCallPart.state)) {
+ if (approvalPart.approved) {
+ toolCallPart.state = "approval-responded";
+ } else {
+ toolCallPart.state = "output-denied";
+ }
+ }
+ }
+ }
+ }
+
+ // Process pre-extracted execution-denied results (extracted before toModelMessage
+ // converted them to text format for provider compatibility)
+ for (const denied of executionDeniedResults) {
+ const toolCallPart = allParts.find(
+ (part) =>
+ "toolCallId" in part && part.toolCallId === denied.toolCallId,
+ ) as ToolUIPart | undefined;
+
+ if (toolCallPart) {
+ toolCallPart.state = "output-denied";
+ if (!("approval" in toolCallPart) || !toolCallPart.approval) {
+ (toolCallPart as ToolUIPart & { approval?: object }).approval = {
+ id: "",
+ approved: false,
+ reason: denied.reason,
+ };
+ } else {
+ const approval = (
+ toolCallPart as ToolUIPart & {
+ approval: { approved?: boolean; reason?: string };
+ }
+ ).approval;
+ approval.approved = false;
+ approval.reason = denied.reason;
+ }
+ }
+ }
+
return {
...common,
role: "assistant",
diff --git a/src/client/start.ts b/src/client/start.ts
index f9124032..d08c4fb7 100644
--- a/src/client/start.ts
+++ b/src/client/start.ts
@@ -81,6 +81,13 @@ export async function startGeneration<
*/
abortSignal?: AbortSignal;
stopWhen?: StopCondition | Array>;
+ /**
+ * If true, the new message will get a fresh order (one higher than the max
+ * existing order) instead of using the promptMessageId's order. Useful for
+ * continuing generation after tool approval where you want the continuation
+ * to be a separate message from the original tool call.
+ */
+ forceNewOrder?: boolean;
_internal?: { generateId?: IdGenerator };
},
{
@@ -133,8 +140,10 @@ export async function startGeneration<
});
const saveMessages = opts.storageOptions?.saveMessages ?? "promptAndOutput";
+ // When forceNewOrder is true, skip creating a pendingMessage because it would
+ // be created with the wrong order. The message will be created fresh when saved.
const { promptMessageId, pendingMessage, savedMessages } =
- threadId && saveMessages !== "none"
+ threadId && saveMessages !== "none" && !args.forceNewOrder
? await saveInputMessages(ctx, component, {
...opts,
userId,
@@ -150,8 +159,16 @@ export async function startGeneration<
savedMessages: [] as MessageDoc[],
};
- const order = pendingMessage?.order ?? context.order;
- const stepOrder = pendingMessage?.stepOrder ?? context.stepOrder;
+ // Determine order for the new message
+ // If forceNewOrder is set, increment from the context order to create a separate message
+ let order = pendingMessage?.order ?? context.order;
+ let stepOrder = pendingMessage?.stepOrder ?? context.stepOrder;
+ const useForceNewOrder = args.forceNewOrder && order !== undefined;
+ if (useForceNewOrder) {
+ // TypeScript can't infer order is defined here from the compound condition above
+ order = order! + 1;
+ stepOrder = 0;
+ }
let pendingMessageId = pendingMessage?._id;
const model = args.model ?? opts.languageModel;
@@ -260,11 +277,15 @@ export async function startGeneration<
userId,
threadId,
agentName: opts.agentName,
- promptMessageId,
+ // When forceNewOrder is true, don't pass promptMessageId and use overrideOrder
+ // to ensure the continuation message gets a fresh order (N+1)
+ promptMessageId: useForceNewOrder ? undefined : promptMessageId,
pendingMessageId,
messages: serialized.messages,
embeddings,
failPendingSteps: false,
+ // Pass the computed order when forceNewOrder is true
+ overrideOrder: useForceNewOrder ? order : undefined,
});
const lastMessage = saved.messages.at(-1)!;
if (createPendingMessage) {
diff --git a/src/client/streamText.ts b/src/client/streamText.ts
index ea500dac..77bfb906 100644
--- a/src/client/streamText.ts
+++ b/src/client/streamText.ts
@@ -145,13 +145,24 @@ export async function streamText<
const stream = streamer?.consumeStream(
result.toUIMessageStream>(),
);
- if (
- (typeof options?.saveStreamDeltas === "object" &&
- !options.saveStreamDeltas.returnImmediately) ||
- options?.saveStreamDeltas === true
- ) {
- await stream;
- await result.consumeStream();
+ let streamConsumed = false;
+ try {
+ if (
+ (typeof options?.saveStreamDeltas === "object" &&
+ !options.saveStreamDeltas.returnImmediately) ||
+ options?.saveStreamDeltas === true
+ ) {
+ await stream;
+ await result.consumeStream();
+ streamConsumed = true;
+ }
+ } catch (error) {
+ // If an error occurs during streaming (e.g., in onStepFinish callbacks),
+ // make sure to abort the streaming message so it doesn't get stuck
+ if (streamer && !streamConsumed) {
+ await streamer.fail(errorToString(error));
+ }
+ throw error;
}
const metadata: GenerationOutputMetadata = {
promptMessageId,
diff --git a/src/client/types.ts b/src/client/types.ts
index 2533d029..87b68d76 100644
--- a/src/client/types.ts
+++ b/src/client/types.ts
@@ -86,6 +86,13 @@ export type AgentPrompt = {
* specified in the Agent config.
*/
model?: LanguageModel;
+ /**
+ * If true, the new message will get a fresh order (one higher than the max
+ * existing order) instead of using the promptMessageId's order. Useful for
+ * continuing generation after tool approval where you want the continuation
+ * to be a separate message from the original tool call.
+ */
+ forceNewOrder?: boolean;
};
export type Config = {
diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts
index f6708edc..6f35f00f 100644
--- a/src/component/_generated/component.ts
+++ b/src/component/_generated/component.ts
@@ -697,6 +697,7 @@ export type ComponentApi =
| { message: string; type: "other" }
>;
}>;
+ overrideOrder?: number;
pendingMessageId?: string;
promptMessageId?: string;
threadId: string;
@@ -1420,22 +1421,19 @@ export type ComponentApi =
toolName: string;
type: "tool-call";
}
- | {
- args: any;
- input?: any;
- providerExecuted?: boolean;
- providerMetadata?: Record<
- string,
- Record
- >;
- providerOptions?: Record<
- string,
- Record
- >;
- toolCallId: string;
- toolName: string;
- type: "tool-call";
- }
+ | {
+ args: any;
+ input?: any;
+ providerExecuted?: boolean;
+ providerMetadata?: Record<
+ string,
+ Record
+ >;
+ providerOptions?: Record>;
+ toolCallId: string;
+ toolName: string;
+ type: "tool-call";
+ }
| {
args?: any;
experimental_content?: Array<
@@ -2533,22 +2531,19 @@ export type ComponentApi =
toolName: string;
type: "tool-call";
}
- | {
- args: any;
- input?: any;
- providerExecuted?: boolean;
- providerMetadata?: Record<
- string,
- Record
- >;
- providerOptions?: Record<
- string,
- Record
- >;
- toolCallId: string;
- toolName: string;
- type: "tool-call";
- }
+ | {
+ args: any;
+ input?: any;
+ providerExecuted?: boolean;
+ providerMetadata?: Record<
+ string,
+ Record
+ >;
+ providerOptions?: Record>;
+ toolCallId: string;
+ toolName: string;
+ type: "tool-call";
+ }
| {
args?: any;
experimental_content?: Array<
@@ -3054,22 +3049,19 @@ export type ComponentApi =
toolName: string;
type: "tool-call";
}
- | {
- args: any;
- input?: any;
- providerExecuted?: boolean;
- providerMetadata?: Record<
- string,
- Record
- >;
- providerOptions?: Record<
- string,
- Record
- >;
- toolCallId: string;
- toolName: string;
- type: "tool-call";
- }
+ | {
+ args: any;
+ input?: any;
+ providerExecuted?: boolean;
+ providerMetadata?: Record<
+ string,
+ Record
+ >;
+ providerOptions?: Record>;
+ toolCallId: string;
+ toolName: string;
+ type: "tool-call";
+ }
| {
args?: any;
experimental_content?: Array<
@@ -4063,22 +4055,19 @@ export type ComponentApi =
toolName: string;
type: "tool-call";
}
- | {
- args: any;
- input?: any;
- providerExecuted?: boolean;
- providerMetadata?: Record<
- string,
- Record
- >;
- providerOptions?: Record<
- string,
- Record
- >;
- toolCallId: string;
- toolName: string;
- type: "tool-call";
- }
+ | {
+ args: any;
+ input?: any;
+ providerExecuted?: boolean;
+ providerMetadata?: Record<
+ string,
+ Record
+ >;
+ providerOptions?: Record>;
+ toolCallId: string;
+ toolName: string;
+ type: "tool-call";
+ }
| {
args?: any;
experimental_content?: Array<
diff --git a/src/component/messages.ts b/src/component/messages.ts
index 627bf19e..8e8d909b 100644
--- a/src/component/messages.ts
+++ b/src/component/messages.ts
@@ -141,6 +141,9 @@ const addMessagesArgs = {
// if set to true, these messages will not show up in text or vector search
// results for the userId
hideFromUserIdSearch: v.optional(v.boolean()),
+ // If provided, forces the messages to use this order instead of computing it.
+ // Used by forceNewOrder to ensure continuation messages get a fresh order.
+ overrideOrder: v.optional(v.number()),
};
export const addMessages = mutation({
args: addMessagesArgs,
@@ -165,6 +168,7 @@ async function addMessagesHandler(
promptMessageId,
pendingMessageId,
hideFromUserIdSearch,
+ overrideOrder,
...rest
} = args;
const promptMessage = promptMessageId && (await ctx.db.get(promptMessageId));
@@ -196,7 +200,11 @@ async function addMessagesHandler(
let order, stepOrder;
let fail = false;
let error: string | undefined;
- if (promptMessageId) {
+ // When overrideOrder is provided, use it directly instead of computing from promptMessage
+ if (overrideOrder !== undefined) {
+ order = overrideOrder;
+ stepOrder = -1; // Will be incremented to 0 for the first message
+ } else if (promptMessageId) {
assert(promptMessage, `Parent message ${promptMessageId} not found`);
if (promptMessage.status === "failed") {
fail = true;
diff --git a/src/mapping.ts b/src/mapping.ts
index de22c780..9a8620ee 100644
--- a/src/mapping.ts
+++ b/src/mapping.ts
@@ -585,21 +585,12 @@ export function toModelMessageContent(
case "source":
return part satisfies SourcePart;
case "tool-approval-request":
- return {
- type: part.type,
- approvalId: part.approvalId,
- toolCallId: part.toolCallId,
- ...metadata,
- } satisfies ToolApprovalRequest;
case "tool-approval-response":
- return {
- type: part.type,
- approvalId: part.approvalId,
- approved: part.approved,
- reason: part.reason,
- providerExecuted: part.providerExecuted,
- ...metadata,
- } satisfies ToolApprovalResponse;
+ // Filter out approval parts - providers like Anthropic don't understand these
+ // and will error if they are included in messages sent to the API.
+ // The approval data is preserved in storage and extracted for UI rendering
+ // directly from message.message.content before toModelMessage is called.
+ return null;
default:
return null;
}
@@ -632,13 +623,26 @@ function normalizeToolResult(
providerMetadata?: ProviderMetadata;
},
): ToolResultPart & Infer {
+ let output = part.output
+ ? validate(vToolResultOutput, part.output)
+ ? (part.output as any)
+ : normalizeToolOutput(JSON.stringify(part.output))
+ : normalizeToolOutput("result" in part ? part.result : undefined);
+
+ // Convert execution-denied to text format for provider compatibility
+ // Anthropic and other providers don't understand the execution-denied type
+ if (output?.type === "execution-denied") {
+ output = {
+ type: "text",
+ value:
+ (output as { reason?: string }).reason ??
+ "Tool execution was denied by the user",
+ };
+ }
+
return {
type: part.type,
- output: part.output
- ? validate(vToolResultOutput, part.output)
- ? (part.output as any)
- : normalizeToolOutput(JSON.stringify(part.output))
- : normalizeToolOutput("result" in part ? part.result : undefined),
+ output,
toolCallId: part.toolCallId,
toolName: part.toolName,
// Preserve isError flag for error reporting
From c3cddfdb6bdefe6aec7c7e34b9b6e5c120dee474 Mon Sep 17 00:00:00 2001
From: Seth Raphael
Date: Sun, 25 Jan 2026 15:59:02 -0800
Subject: [PATCH 02/22] Remove debug logging from approval example
Co-Authored-By: Claude Opus 4.5
---
example/convex/chat/approval.ts | 39 +--------------------------------
1 file changed, 1 insertion(+), 38 deletions(-)
diff --git a/example/convex/chat/approval.ts b/example/convex/chat/approval.ts
index 81509fa6..589b5f9f 100644
--- a/example/convex/chat/approval.ts
+++ b/example/convex/chat/approval.ts
@@ -53,24 +53,7 @@ export const generateResponse = internalAction({
const result = await approvalAgent.streamText(
ctx,
{ threadId },
- {
- promptMessageId,
- onStepFinish: (step) => {
- console.log("Step finished:", {
- finishReason: step.finishReason,
- toolCallsCount: step.toolCalls.length,
- toolResultsCount: step.toolResults.length,
- contentTypes: step.content.map((c) => c.type),
- responseMessagesCount: step.response.messages.length,
- responseMessages: step.response.messages.map((m) => ({
- role: m.role,
- contentTypes: Array.isArray(m.content)
- ? m.content.map((c: { type: string }) => c.type)
- : typeof m.content,
- })),
- });
- },
- },
+ { promptMessageId },
{ saveStreamDeltas: { chunking: "word", throttleMs: 100 } },
);
await result.consumeStream();
@@ -347,26 +330,6 @@ export const listThreadMessages = query({
const paginated = await listUIMessages(ctx, components.agent, args);
- // Debug logging
- if (streams?.kind === "list" && streams.messages.length > 0) {
- console.log("[listThreadMessages] Active streams:", streams.messages.map(m => ({
- streamId: m.streamId,
- order: m.order,
- stepOrder: m.stepOrder,
- status: m.status,
- })));
- }
- if (paginated.page.length > 0) {
- console.log("[listThreadMessages] Paginated UIMessages:", paginated.page.map(m => ({
- order: m.order,
- stepOrder: m.stepOrder,
- status: m.status,
- role: m.role,
- textLen: m.text?.length,
- partsCount: m.parts?.length,
- })));
- }
-
return {
...paginated,
streams,
From ddce09b1cf84524e0d6842337ad8bcc86d8fd532 Mon Sep 17 00:00:00 2001
From: Seth Raphael
Date: Sun, 25 Jan 2026 16:12:20 -0800
Subject: [PATCH 03/22] Add approveToolCall and denyToolCall helper methods to
Agent
These methods encapsulate the complexity of the AI SDK v6 tool approval
workflow, reducing boilerplate in application code.
Before: ~340 lines of manual tool finding, execution, and message handling
After: ~140 lines using the clean helper API
The helpers handle:
- Finding the tool call info from the approval ID
- Executing the tool (for approval) with proper context injection
- Saving tool-approval-response and tool-result messages
- Continuing generation with forceNewOrder for clean message separation
This is a workaround for AI SDK v6 issue #10980 where the native approval
flow doesn't generate proper tool_result blocks for Anthropic.
Co-Authored-By: Claude Opus 4.5
---
example/convex/chat/approval.ts | 240 +++++---------------------------
src/client/index.ts | 226 ++++++++++++++++++++++++++++++
2 files changed, 257 insertions(+), 209 deletions(-)
diff --git a/example/convex/chat/approval.ts b/example/convex/chat/approval.ts
index 589b5f9f..f7f93b5b 100644
--- a/example/convex/chat/approval.ts
+++ b/example/convex/chat/approval.ts
@@ -62,7 +62,7 @@ export const generateResponse = internalAction({
/**
* Submit an approval response for a pending tool call.
- * After approval, executes the tool and continues the generation.
+ * Schedules the appropriate action to handle approval or denial.
*/
export const submitApproval = mutation({
args: {
@@ -74,106 +74,17 @@ export const submitApproval = mutation({
handler: async (ctx, { threadId, approvalId, approved, reason }) => {
await authorizeThreadAccess(ctx, threadId);
- // Find the assistant message that contains the tool-approval-request with this approvalId
- // We need this to set the correct promptMessageId so the approval response
- // is grouped with the tool call during UIMessage construction
- const messagesResult = await approvalAgent.listMessages(ctx, {
- threadId,
- paginationOpts: { numItems: 20, cursor: null },
- });
-
- let parentMessageId: string | undefined;
- let toolCallId: string | undefined;
- let toolName: string | undefined;
-
- // First pass: find the tool-approval-request to get toolCallId and parent message
- // This must be a separate pass because tool-call comes BEFORE tool-approval-request
- // in the content array, so toolCallId isn't set yet when we first see tool-call
- for (const msg of messagesResult.page) {
- if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) {
- for (const part of msg.message.content) {
- if (part.type === "tool-approval-request" && part.approvalId === approvalId) {
- parentMessageId = msg._id;
- toolCallId = part.toolCallId;
- break;
- }
- }
- }
- if (toolCallId) break;
- }
-
- // Second pass: find the tool-call with matching toolCallId to get toolName
- if (toolCallId) {
- for (const msg of messagesResult.page) {
- if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) {
- for (const part of msg.message.content) {
- if (part.type === "tool-call" && part.toolCallId === toolCallId) {
- toolName = part.toolName;
- break;
- }
- }
- }
- if (toolName) break;
- }
- }
-
- // Save the approval response - this updates the UI to show approval/denial status
- // The tool-approval-response is processed by listUIMessages to update tool part state
- // By setting promptMessageId, it will have the same order as the assistant message
- await approvalAgent.saveMessage(ctx, {
- threadId,
- promptMessageId: parentMessageId,
- message: {
- role: "tool",
- content: [
- {
- type: "tool-approval-response",
- approvalId,
- approved,
- reason: reason,
- },
- ],
- },
- skipEmbeddings: true,
- });
-
if (approved) {
- // Schedule the action to execute the approved tool and continue
- await ctx.scheduler.runAfter(0, internal.chat.approval.executeApprovedTool, {
+ await ctx.scheduler.runAfter(0, internal.chat.approval.handleApproval, {
threadId,
approvalId,
+ reason,
});
- } else if (toolCallId && toolName) {
- // For denial, save a tool-result with execution-denied output.
- // This is required by Anthropic's API which expects every tool_use to have
- // a corresponding tool_result in the next message.
- // Group with original message using promptMessageId.
- const { messageId: toolResultId } = await approvalAgent.saveMessage(ctx, {
- threadId,
- promptMessageId: parentMessageId,
- message: {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId,
- toolName,
- output: {
- type: "execution-denied",
- reason: reason ?? "Tool execution was denied by the user",
- },
- },
- ],
- },
- skipEmbeddings: true,
- });
-
- // Continue generation so the LLM can respond to the denial
- // Use forceNewOrder to create a separate message from the original tool call.
- await ctx.scheduler.runAfter(0, internal.chat.approval.continueGeneration, {
+ } else {
+ await ctx.scheduler.runAfter(0, internal.chat.approval.handleDenial, {
threadId,
- promptMessageId: toolResultId,
- forceNewOrder: true,
+ approvalId,
+ reason,
});
}
@@ -182,128 +93,41 @@ export const submitApproval = mutation({
});
/**
- * Execute an approved tool and continue generation.
- * This action finds the pending tool call, executes it, saves the result,
- * and then continues the generation.
+ * Handle an approved tool call.
+ * Uses the Agent helper to execute the tool and continue generation.
*/
-export const executeApprovedTool = internalAction({
- args: { threadId: v.string(), approvalId: v.string() },
- handler: async (ctx, { threadId, approvalId }) => {
- // Get recent messages to find the pending tool call
- const messagesResult = await approvalAgent.listMessages(ctx, {
- threadId,
- paginationOpts: { numItems: 20, cursor: null },
- });
-
- // Find the tool-approval-request and tool-call with this approvalId
- let toolCallId: string | undefined;
- let toolName: string | undefined;
- let toolInput: Record | undefined;
- let parentMessageId: string | undefined;
-
- // First pass: find the toolCallId and parent message from the approval request
- for (const msg of messagesResult.page) {
- if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) {
- for (const part of msg.message.content) {
- if (part.type === "tool-approval-request" && part.approvalId === approvalId) {
- toolCallId = part.toolCallId;
- parentMessageId = msg._id;
- break;
- }
- }
- }
- if (toolCallId) break;
- }
-
- // Second pass: find the tool-call with matching toolCallId
- if (toolCallId) {
- for (const msg of messagesResult.page) {
- if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) {
- for (const part of msg.message.content) {
- if (part.type === "tool-call" && part.toolCallId === toolCallId) {
- toolName = part.toolName;
- toolInput = part.input ?? (part as Record).args ?? {};
- break;
- }
- }
- }
- if (toolName) break;
- }
- }
-
- if (!toolCallId || !toolName || !toolInput) {
- console.error("Could not find tool call for approval", { approvalId, toolCallId, toolName });
- return;
- }
-
- // Get the tool and wrap it with context
- const tools = approvalAgent.options.tools as Record | undefined;
- const tool = tools?.[toolName];
- if (!tool) {
- console.error("Tool not found", { toolName });
- return;
- }
-
- // Execute the tool with context injected (like wrapTools does)
- let result: string;
- try {
- const wrappedTool = { ...tool, ctx };
- const output = await wrappedTool.execute.call(wrappedTool, toolInput, {
- toolCallId,
- messages: [],
- });
- result = typeof output === "string" ? output : JSON.stringify(output);
- } catch (error) {
- result = `Error: ${error instanceof Error ? error.message : String(error)}`;
- console.error("Tool execution error:", error);
- }
-
- // Save the tool result - group with original message using promptMessageId
- const { messageId: toolResultId } = await approvalAgent.saveMessage(ctx, {
+export const handleApproval = internalAction({
+ args: {
+ threadId: v.string(),
+ approvalId: v.string(),
+ reason: v.optional(v.string()),
+ },
+ handler: async (ctx, { threadId, approvalId, reason }) => {
+ const result = await approvalAgent.approveToolCall(ctx, {
threadId,
- promptMessageId: parentMessageId,
- message: {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId,
- toolName,
- output: { type: "text", value: result },
- },
- ],
- },
- skipEmbeddings: true,
+ approvalId,
+ reason,
});
-
- // Continue generation so LLM can respond to the tool result.
- // Use forceNewOrder to create a separate message from the original tool call.
- const streamResult = await approvalAgent.streamText(
- ctx,
- { threadId },
- { promptMessageId: toolResultId, forceNewOrder: true },
- { saveStreamDeltas: { chunking: "word", throttleMs: 100 } },
- );
- await streamResult.consumeStream();
+ await result.consumeStream();
},
});
/**
- * Continue generation after tool approval/denial.
+ * Handle a denied tool call.
+ * Uses the Agent helper to save the denial and let the LLM respond.
*/
-export const continueGeneration = internalAction({
+export const handleDenial = internalAction({
args: {
- promptMessageId: v.string(),
threadId: v.string(),
- forceNewOrder: v.optional(v.boolean()),
+ approvalId: v.string(),
+ reason: v.optional(v.string()),
},
- handler: async (ctx, { promptMessageId, threadId, forceNewOrder }) => {
- const result = await approvalAgent.streamText(
- ctx,
- { threadId },
- { promptMessageId, forceNewOrder },
- { saveStreamDeltas: { chunking: "word", throttleMs: 100 } },
- );
+ handler: async (ctx, { threadId, approvalId, reason }) => {
+ const result = await approvalAgent.denyToolCall(ctx, {
+ threadId,
+ approvalId,
+ reason,
+ });
await result.consumeStream();
},
});
@@ -324,8 +148,6 @@ export const listThreadMessages = query({
const streams = await syncStreams(ctx, components.agent, {
threadId,
streamArgs,
- // Only include streaming - finished messages come from pagination.
- // Tool approval UI data comes from message content, not streams.
});
const paginated = await listUIMessages(ctx, components.agent, args);
diff --git a/src/client/index.ts b/src/client/index.ts
index dd0041f9..b0444f12 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -1508,4 +1508,230 @@ export class Agent<
},
});
}
+
+ /**
+ * Approve a pending tool call and continue generation.
+ *
+ * This is a helper for the AI SDK v6 tool approval workflow. When a tool
+ * with `needsApproval: true` is called, it returns a `tool-approval-request`.
+ * Call this method to approve the tool, execute it, and continue generation.
+ *
+ * @param ctx The context from an action.
+ * @param args.threadId The thread containing the tool call.
+ * @param args.approvalId The approval ID from the tool-approval-request.
+ * @param args.reason Optional reason for the approval.
+ * @returns The result of the continued generation.
+ */
+ async approveToolCall(
+ ctx: ActionCtx & CustomCtx,
+ args: {
+ threadId: string;
+ approvalId: string;
+ reason?: string;
+ },
+ ): Promise & GenerationOutputMetadata> {
+ const { threadId, approvalId, reason } = args;
+ const toolInfo = await this._findToolCallInfo(ctx, threadId, approvalId);
+
+ if (!toolInfo) {
+ throw new Error(`Could not find tool call for approval ID: ${approvalId}`);
+ }
+
+ const { toolCallId, toolName, toolInput, parentMessageId } = toolInfo;
+
+ // Execute the tool
+ const tools = this.options.tools as Record | undefined;
+ const tool = tools?.[toolName];
+ if (!tool) {
+ throw new Error(`Tool not found: ${toolName}`);
+ }
+
+ let result: string;
+ try {
+ // Execute with context injection (like wrapTools does)
+ const toolCtx = {
+ ...ctx,
+ userId: undefined,
+ threadId,
+ agent: this,
+ };
+ const wrappedTool = tool.__acceptsCtx ? { ...tool, ctx: toolCtx } : tool;
+ const output = await wrappedTool.execute.call(wrappedTool, toolInput, {
+ toolCallId,
+ messages: [],
+ });
+ result = typeof output === "string" ? output : JSON.stringify(output);
+ } catch (error) {
+ result = `Error: ${error instanceof Error ? error.message : String(error)}`;
+ console.error("Tool execution error:", error);
+ }
+
+ // Save approval response and tool result together
+ const { messageId: toolResultId } = await this.saveMessage(ctx, {
+ threadId,
+ promptMessageId: parentMessageId,
+ message: {
+ role: "tool",
+ content: [
+ {
+ type: "tool-approval-response",
+ approvalId,
+ approved: true,
+ reason,
+ },
+ {
+ type: "tool-result",
+ toolCallId,
+ toolName,
+ output: { type: "text", value: result },
+ },
+ ],
+ },
+ skipEmbeddings: true,
+ });
+
+ // Continue generation with forceNewOrder to create a separate message
+ return this.streamText(
+ ctx,
+ { threadId },
+ { promptMessageId: toolResultId, forceNewOrder: true },
+ { saveStreamDeltas: { chunking: "word", throttleMs: 100 } },
+ );
+ }
+
+ /**
+ * Deny a pending tool call and continue generation.
+ *
+ * This is a helper for the AI SDK v6 tool approval workflow. When a tool
+ * with `needsApproval: true` is called, it returns a `tool-approval-request`.
+ * Call this method to deny the tool and let the LLM respond to the denial.
+ *
+ * @param ctx The context from an action.
+ * @param args.threadId The thread containing the tool call.
+ * @param args.approvalId The approval ID from the tool-approval-request.
+ * @param args.reason Optional reason for the denial.
+ * @returns The result of the continued generation.
+ */
+ async denyToolCall(
+ ctx: ActionCtx & CustomCtx,
+ args: {
+ threadId: string;
+ approvalId: string;
+ reason?: string;
+ },
+ ): Promise & GenerationOutputMetadata> {
+ const { threadId, approvalId, reason } = args;
+ const toolInfo = await this._findToolCallInfo(ctx, threadId, approvalId);
+
+ if (!toolInfo) {
+ throw new Error(`Could not find tool call for approval ID: ${approvalId}`);
+ }
+
+ const { toolCallId, toolName, parentMessageId } = toolInfo;
+ const denialReason = reason ?? "Tool execution was denied by the user";
+
+ // Save approval response (denied) and tool result with execution-denied
+ const { messageId: toolResultId } = await this.saveMessage(ctx, {
+ threadId,
+ promptMessageId: parentMessageId,
+ message: {
+ role: "tool",
+ content: [
+ {
+ type: "tool-approval-response",
+ approvalId,
+ approved: false,
+ reason: denialReason,
+ },
+ {
+ type: "tool-result",
+ toolCallId,
+ toolName,
+ output: {
+ type: "execution-denied",
+ reason: denialReason,
+ },
+ },
+ ],
+ },
+ skipEmbeddings: true,
+ });
+
+ // Continue generation with forceNewOrder to create a separate message
+ return this.streamText(
+ ctx,
+ { threadId },
+ { promptMessageId: toolResultId, forceNewOrder: true },
+ { saveStreamDeltas: { chunking: "word", throttleMs: 100 } },
+ );
+ }
+
+ /**
+ * Find tool call information for an approval ID.
+ * @internal
+ */
+ private async _findToolCallInfo(
+ ctx: ActionCtx,
+ threadId: string,
+ approvalId: string,
+ ): Promise<{
+ toolCallId: string;
+ toolName: string;
+ toolInput: Record;
+ parentMessageId: string;
+ } | null> {
+ const messagesResult = await this.listMessages(ctx, {
+ threadId,
+ paginationOpts: { numItems: 20, cursor: null },
+ });
+
+ let toolCallId: string | undefined;
+ let parentMessageId: string | undefined;
+ let toolName: string | undefined;
+ let toolInput: Record | undefined;
+
+ // First pass: find the approval request to get toolCallId and parent message
+ for (const msg of messagesResult.page) {
+ if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) {
+ for (const part of msg.message.content) {
+ if (
+ part.type === "tool-approval-request" &&
+ (part as any).approvalId === approvalId
+ ) {
+ parentMessageId = msg._id;
+ toolCallId = (part as any).toolCallId;
+ break;
+ }
+ }
+ }
+ if (toolCallId) break;
+ }
+
+ if (!toolCallId || !parentMessageId) {
+ return null;
+ }
+
+ // Second pass: find the tool-call with matching toolCallId to get toolName and input
+ for (const msg of messagesResult.page) {
+ if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) {
+ for (const part of msg.message.content) {
+ if (
+ part.type === "tool-call" &&
+ (part as any).toolCallId === toolCallId
+ ) {
+ toolName = (part as any).toolName;
+ toolInput = (part as any).input ?? (part as any).args ?? {};
+ break;
+ }
+ }
+ }
+ if (toolName) break;
+ }
+
+ if (!toolName || !toolInput) {
+ return null;
+ }
+
+ return { toolCallId, toolName, toolInput, parentMessageId };
+ }
}
From 05a5f1946e8f99f2c4558cb1c1fc96507d0dca68 Mon Sep 17 00:00:00 2001
From: Seth Raphael
Date: Sun, 25 Jan 2026 16:24:58 -0800
Subject: [PATCH 04/22] Remove noisy warning for paginated tool results
When pagination cuts off a tool-result from its tool-call, we handle it
gracefully by creating a standalone tool part. The warning was noisy for
this expected pagination behavior.
Co-Authored-By: Claude Opus 4.5
---
src/UIMessages.ts | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/src/UIMessages.ts b/src/UIMessages.ts
index 29400ef8..38031390 100644
--- a/src/UIMessages.ts
+++ b/src/UIMessages.ts
@@ -556,10 +556,7 @@ function createAssistantUIMessage<
call.output = output;
}
} else {
- console.warn(
- "Tool result without preceding tool call.. adding anyways",
- contentPart,
- );
+ // Tool call is on a previous page - create standalone tool part
if (hasError) {
allParts.push({
type: `tool-${contentPart.toolName}`,
From 78dcc309bf2a9d1088b7ba756bb73f205320e0d2 Mon Sep 17 00:00:00 2001
From: Seth Raphael
Date: Sun, 25 Jan 2026 16:32:09 -0800
Subject: [PATCH 05/22] Add specific types for AI SDK v6 usage token details
- inputTokenDetails: noCacheTokens, cacheReadTokens, cacheWriteTokens
- outputTokenDetails: textTokens, reasoningTokens
- raw: kept as v.any() since it's provider-specific
Co-Authored-By: Claude Opus 4.5
---
example/convex/usage_tracking/usageHandler.ts | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/example/convex/usage_tracking/usageHandler.ts b/example/convex/usage_tracking/usageHandler.ts
index c233bc59..e63d6ea4 100644
--- a/example/convex/usage_tracking/usageHandler.ts
+++ b/example/convex/usage_tracking/usageHandler.ts
@@ -37,9 +37,21 @@ export const insertRawUsage = internalMutation({
outputTokens: v.optional(v.number()),
reasoningTokens: v.optional(v.number()),
cachedInputTokens: v.optional(v.number()),
- // AI SDK v6 adds these detailed token breakdown fields
- inputTokenDetails: v.optional(v.any()),
- outputTokenDetails: v.optional(v.any()),
+ // AI SDK v6 detailed token breakdown fields
+ inputTokenDetails: v.optional(
+ v.object({
+ noCacheTokens: v.optional(v.number()),
+ cacheReadTokens: v.optional(v.number()),
+ cacheWriteTokens: v.optional(v.number()),
+ })
+ ),
+ outputTokenDetails: v.optional(
+ v.object({
+ textTokens: v.optional(v.number()),
+ reasoningTokens: v.optional(v.number()),
+ })
+ ),
+ // Provider-specific raw usage data (varies by provider)
raw: v.optional(v.any()),
}),
providerMetadata: v.optional(vProviderMetadata),
From 3a29dd1a8d390f53621f4ad92d4ac558d3063f1f Mon Sep 17 00:00:00 2001
From: Seth Raphael
Date: Sun, 25 Jan 2026 20:07:31 -0800
Subject: [PATCH 06/22] Enhance CLAUDE.md with comprehensive AI upgrade
guidance
- Add tool approval flow to Key Patterns architecture section
- Replace upgrade section with detailed AI-friendly guidance including:
- Detection patterns for v5 code (parameters, handler, textEmbeddingModel)
- Dependency update commands
- Before/after transformation examples for tools, embeddings, step limits
- Verification steps and common issues
- New v6 features (tool approval, reasoning streaming, token details)
- Remove outdated TODO comment in deltas.ts (partial tool calls now handled)
Co-Authored-By: Claude Opus 4.5
---
CLAUDE.md | 91 +++++++++++++++++++++++++++++----------------------
src/deltas.ts | 1 -
2 files changed, 52 insertions(+), 40 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 19fa3569..a02ade8a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -59,6 +59,7 @@ npm run typecheck # Full TypeScript validation including example/convex
- **Message-centric design** - All operations revolve around message persistence/retrieval
- **Component-based encapsulation** - Uses Convex Components pattern
- **Multi-user support** - Threads have optional `userId` for ownership
+- **Tool approval flow** - Tools can require human approval via `needsApproval`, with `agent.approveToolCall()` and `agent.denyToolCall()` methods
### Database Tables (Convex Component)
@@ -87,82 +88,94 @@ Follow the rules in `.cursor/rules/convex_rules.mdc`:
- `@convex-dev/agent/validators` - Convex validators for integration
- `@convex-dev/agent/test` - Testing utilities
-## Upgrading to v0.6.0 (AI SDK v6)
+## AI Guidance: Helping Users Upgrade to v0.6.0
-When helping users upgrade from @convex-dev/agent v0.3.x to v0.6.0, follow these steps:
+When helping users upgrade from @convex-dev/agent v0.3.x (AI SDK v5) to v0.6.0 (AI SDK v6):
-### Step 1: Update dependencies in package.json
+### Step 1: Detect v5 Patterns
-```bash
-npm install @convex-dev/agent@^0.6.0 ai@^6.0.35 @ai-sdk/provider-utils@^4.0.6
-```
+Search for these patterns indicating v5 usage:
+- `createTool({ parameters:` - should be `inputSchema`
+- `createTool({ handler:` - should be `execute`
+- `textEmbeddingModel:` - should be `embeddingModel`
+- `maxSteps:` in generateText/streamText - should be `stopWhen: stepCountIs(N)`
+- `@ai-sdk/*` packages at v1.x or v2.x - should be v3.x
+
+### Step 2: Update Dependencies
-Also update any AI SDK provider packages:
```bash
-npm install @ai-sdk/openai@^3.0.10 @ai-sdk/anthropic@^3.0.13
+npm install @convex-dev/agent@^0.6.0 ai@^6.0.35 @ai-sdk/provider-utils@^4.0.6
+npm install @ai-sdk/openai@^3.0.10 # or whichever provider
```
-### Step 2: Update tool definitions
-
-Replace `parameters` with `inputSchema`:
+### Step 3: Apply Transformations
+**Tool definitions:**
```typescript
-// Before (v5)
+// BEFORE (v5)
const myTool = createTool({
description: "...",
parameters: z.object({ query: z.string() }),
- execute: async (ctx, args) => { ... }
+ handler: async (ctx, args) => {
+ return args.query.toUpperCase();
+ }
})
-// After (v6)
+// AFTER (v6)
const myTool = createTool({
description: "...",
inputSchema: z.object({ query: z.string() }),
- execute: async (ctx, input, options) => { ... }
+ execute: async (ctx, input, options) => {
+ return input.query.toUpperCase();
+ }
})
```
-### Step 3: Update maxSteps usage (if applicable)
+**Agent embedding config:**
+```typescript
+// BEFORE
+new Agent(components.agent, {
+ textEmbeddingModel: openai.embedding("text-embedding-3-small")
+})
+
+// AFTER
+new Agent(components.agent, {
+ embeddingModel: openai.embedding("text-embedding-3-small")
+})
+```
+**Step limits:**
```typescript
-// Before (v5)
+// BEFORE
await agent.generateText(ctx, { threadId }, {
prompt: "...",
maxSteps: 5
})
-// After (v6) - maxSteps still works but stopWhen is preferred
-import { stepCountIs } from "ai"
+// AFTER
+import { stepCountIs } from "@convex-dev/agent"
await agent.generateText(ctx, { threadId }, {
prompt: "...",
stopWhen: stepCountIs(5)
})
```
-### Step 4: Update embedding model config (optional)
-
-```typescript
-// Before
-new Agent(components.agent, {
- textEmbeddingModel: openai.embedding("text-embedding-3-small")
-})
-
-// After (textEmbeddingModel still works but embeddingModel is preferred)
-new Agent(components.agent, {
- embeddingModel: openai.embedding("text-embedding-3-small")
-})
-```
-
-### Step 5: Verify the upgrade
+### Step 4: Verify
```bash
npm run typecheck
-npm run lint
npm test
```
-### Common issues
+### Common Issues
+
+- **EmbeddingModelV2 vs V3 errors**: Ensure all `@ai-sdk/*` packages are v3.x
+- **Tool `args` vs `input`**: v6 uses `input` in execute signature (2nd param)
+- **`mimeType` vs `mediaType`**: v6 prefers `mediaType` (backwards compat maintained)
+
+### New v6 Features to Mention
-- **EmbeddingModelV2 vs V3 errors**: Ensure all @ai-sdk/* packages are updated to v3.x
-- **Tool input/args**: v6 uses `input` instead of `args` in tool calls (backwards compat maintained)
-- **mimeType vs mediaType**: v6 uses `mediaType` (backwards compat maintained)
+After upgrade, users can now use:
+- **Tool approval**: `needsApproval` in createTool, `agent.approveToolCall()`, `agent.denyToolCall()`
+- **Reasoning streaming**: Works with models like Groq that support reasoning
+- **Detailed token usage**: `inputTokenDetails`, `outputTokenDetails` in usage tracking
diff --git a/src/deltas.ts b/src/deltas.ts
index 87a8f9f7..0e816d04 100644
--- a/src/deltas.ts
+++ b/src/deltas.ts
@@ -124,7 +124,6 @@ export async function deriveUIMessagesFromDeltas(
blankUIMessage(streamMessage, threadId),
parts,
);
- // TODO: this fails on partial tool calls
messages.push(uiMessage);
} else {
const [uiMessages] = deriveUIMessagesFromTextStreamParts(
From 820fdea9662519174cfcf851379cc8ce50991e2a Mon Sep 17 00:00:00 2001
From: Seth Raphael
Date: Sun, 25 Jan 2026 21:13:34 -0800
Subject: [PATCH 07/22] Add compile-time errors for AI SDK v5 patterns
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When users upgrade to v0.6.0 with old AI SDK v5 dependencies, TypeScript
now shows helpful error messages pointing to the migration guide:
- languageModel: Shows error if model has specificationVersion "v2" instead of "v3"
- createTool args: Shows "⚠️ 'args' was removed... Rename to 'inputSchema'"
- createTool handler: Shows "⚠️ 'handler' was removed... Rename to 'execute'"
This helps users (and AI assistants) understand what needs to change
before they try to run the code.
Also adds scripts/check-upgrade.js CLI tool for scanning codebases.
Co-Authored-By: Claude Opus 4.5
---
package.json | 6 +-
scripts/check-upgrade.js | 140 +++++++++++++++++++++++++++++++++++++++
src/client/createTool.ts | 100 ++++++++++++++--------------
src/client/types.ts | 26 ++++----
4 files changed, 208 insertions(+), 64 deletions(-)
create mode 100755 scripts/check-upgrade.js
diff --git a/package.json b/package.json
index f90ff564..cd39d93a 100644
--- a/package.json
+++ b/package.json
@@ -39,9 +39,13 @@
"release": "npm version patch && npm publish && git push --follow-tags",
"version": "vim -c 'normal o' -c 'normal o## '$npm_package_version CHANGELOG.md && prettier -w CHANGELOG.md && git add CHANGELOG.md"
},
+ "bin": {
+ "convex-agent-upgrade-check": "./scripts/check-upgrade.js"
+ },
"files": [
"dist",
- "src"
+ "src",
+ "scripts"
],
"exports": {
"./package.json": "./package.json",
diff --git a/scripts/check-upgrade.js b/scripts/check-upgrade.js
new file mode 100755
index 00000000..eb85cddf
--- /dev/null
+++ b/scripts/check-upgrade.js
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+
+/**
+ * Pre-typecheck script that detects AI SDK v5 patterns and provides
+ * helpful upgrade instructions before TypeScript errors confuse users.
+ *
+ * Run with: node scripts/check-upgrade.js [directory]
+ */
+
+import { readFileSync, readdirSync, statSync } from 'fs';
+import { join, relative } from 'path';
+
+const V5_PATTERNS = [
+ {
+ pattern: /LanguageModelV2/g,
+ message: 'LanguageModelV2 → LanguageModelV3',
+ fix: "Change 'LanguageModelV2' to 'LanguageModelV3' (or just use 'LanguageModel' from 'ai')",
+ },
+ {
+ pattern: /EmbeddingModel\s*<\s*string\s*>/g,
+ message: 'EmbeddingModel → EmbeddingModel',
+ fix: "Remove the generic parameter: 'EmbeddingModel' → 'EmbeddingModel'",
+ },
+ {
+ pattern: /textEmbeddingModel\s*:/g,
+ message: 'textEmbeddingModel → embeddingModel',
+ fix: "Rename 'textEmbeddingModel' to 'embeddingModel' in your Agent config",
+ },
+ {
+ pattern: /createTool\(\s*\{[^}]*\bargs\s*:/gs,
+ message: 'createTool args → inputSchema',
+ fix: "In createTool(), rename 'args' to 'inputSchema'",
+ },
+ {
+ pattern: /\bhandler\s*:\s*async\s*\(/g,
+ message: 'createTool handler → execute',
+ fix: "In createTool(), rename 'handler' to 'execute' and update signature: execute: async (ctx, input, options)",
+ },
+ {
+ pattern: /@ai-sdk\/provider['"];\s*$/gm,
+ message: '@ai-sdk/provider v2 types',
+ fix: "Update @ai-sdk/* packages to v3.x: npm install @ai-sdk/openai@^3.0.10",
+ filePattern: /\.(ts|tsx)$/,
+ },
+];
+
+function findFiles(dir, extensions = ['.ts', '.tsx']) {
+ const files = [];
+
+ function walk(currentDir) {
+ try {
+ const entries = readdirSync(currentDir);
+ for (const entry of entries) {
+ if (entry === 'node_modules' || entry === '_generated' || entry.startsWith('.')) {
+ continue;
+ }
+ const fullPath = join(currentDir, entry);
+ const stat = statSync(fullPath);
+ if (stat.isDirectory()) {
+ walk(fullPath);
+ } else if (extensions.some(ext => entry.endsWith(ext))) {
+ files.push(fullPath);
+ }
+ }
+ } catch (e) {
+ // Skip directories we can't read
+ }
+ }
+
+ walk(dir);
+ return files;
+}
+
+function checkFile(filePath, baseDir) {
+ const content = readFileSync(filePath, 'utf-8');
+ const issues = [];
+
+ for (const { pattern, message, fix, filePattern } of V5_PATTERNS) {
+ if (filePattern && !filePattern.test(filePath)) {
+ continue;
+ }
+
+ // Reset regex state
+ pattern.lastIndex = 0;
+
+ let match;
+ while ((match = pattern.exec(content)) !== null) {
+ const lines = content.slice(0, match.index).split('\n');
+ const line = lines.length;
+ const col = lines[lines.length - 1].length + 1;
+
+ issues.push({
+ file: relative(baseDir, filePath),
+ line,
+ col,
+ message,
+ fix,
+ match: match[0].slice(0, 50),
+ });
+ }
+ }
+
+ return issues;
+}
+
+function main() {
+ const targetDir = process.argv[2] || process.cwd();
+ const files = findFiles(targetDir);
+ const allIssues = [];
+
+ for (const file of files) {
+ const issues = checkFile(file, targetDir);
+ allIssues.push(...issues);
+ }
+
+ if (allIssues.length === 0) {
+ console.log('✅ No AI SDK v5 patterns detected. Ready for v6!');
+ process.exit(0);
+ }
+
+ console.error('\n' + '='.repeat(70));
+ console.error('⚠️ AI SDK v5 → v6 UPGRADE REQUIRED');
+ console.error('='.repeat(70));
+ console.error('\nFound', allIssues.length, 'pattern(s) that need updating:\n');
+
+ for (const issue of allIssues) {
+ console.error(`📍 ${issue.file}:${issue.line}:${issue.col}`);
+ console.error(` ${issue.message}`);
+ console.error(` Fix: ${issue.fix}`);
+ console.error('');
+ }
+
+ console.error('='.repeat(70));
+ console.error('📚 Full upgrade guide: https://github.com/get-convex/agent/blob/main/MIGRATION.md');
+ console.error('='.repeat(70) + '\n');
+
+ process.exit(1);
+}
+
+main();
diff --git a/src/client/createTool.ts b/src/client/createTool.ts
index eb42d15b..e263d8e0 100644
--- a/src/client/createTool.ts
+++ b/src/client/createTool.ts
@@ -72,60 +72,57 @@ type NeverOptional = 0 extends 1 & N
? Partial>
: T;
+/**
+ * Error message type for deprecated 'handler' property.
+ * Using a string literal type causes TypeScript to show this message in errors.
+ */
+type HANDLER_REMOVED_ERROR =
+ "⚠️ 'handler' was removed in @convex-dev/agent v0.6.0. Rename to 'execute'. See: https://github.com/get-convex/agent/blob/main/MIGRATION.md";
+
export type ToolOutputPropertiesCtx<
INPUT,
OUTPUT,
Ctx extends ToolCtx = ToolCtx,
> = NeverOptional<
OUTPUT,
- | {
- /**
- * An async function that is called with the arguments from the tool call and produces a result.
- * If `execute` (or `handler`) is not provided, the tool will not be executed automatically.
- *
- * @param input - The input of the tool call.
- * @param options.abortSignal - A signal that can be used to abort the tool call.
- */
- execute: ToolExecuteFunctionCtx;
- outputSchema?: FlexibleSchema