From 64ee34d35f09b7c2a950b15e33251f4cb4724eaa Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sat, 17 Jan 2026 03:15:28 +1000 Subject: [PATCH] fix: handle missing tool results in conversation history AI SDK requires every tool call to have a matching tool result. When a tool call's result is missing (e.g., due to network error or interrupted execution), the conversation would break and users couldn't send new messages. This fix: - Builds a set of tool call IDs that have corresponding tool results - Only includes tool calls in assistant messages that have matching results - Falls back to text-only content if all tool calls are missing results --- .../conversation/[conversationId]/route.ts | 56 ++++++++++++------- .../conversation/[conversationId]/route.ts | 56 ++++++++++++------- .../conversation/[conversationId]/route.ts | 56 ++++++++++++------- 3 files changed, 111 insertions(+), 57 deletions(-) diff --git a/apps/web/src/app/api/[username]/agents/[agentSlug]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/[username]/agents/[agentSlug]/conversation/[conversationId]/route.ts index f90e974..414d8df 100644 --- a/apps/web/src/app/api/[username]/agents/[agentSlug]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/[username]/agents/[agentSlug]/conversation/[conversationId]/route.ts @@ -231,6 +231,12 @@ export async function POST(request: NextRequest, context: RouteContext): Promise }); } + // Build a set of tool call IDs that have corresponding tool results + // This is needed because AI SDK requires every tool call to have a matching tool result + const toolResultIds = new Set( + recentMessages.filter((m) => m.role === 'TOOL' && m.toolCallId).map((m) => m.toolCallId) + ); + // Add conversation history - properly format for AI SDK for (const msg of recentMessages) { if (msg.role === 'USER') { @@ -238,27 +244,39 @@ export async function POST(request: NextRequest, context: RouteContext): Promise } else if (msg.role === 'ASSISTANT') { // For assistant messages with tool calls, include ToolCallParts in content if (msg.toolCalls && Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) { - const toolCallParts = ( + // Only include tool calls that have matching tool results + // AI SDK fails if there's a tool call without a corresponding tool result + const validToolCalls = ( msg.toolCalls as Array<{ toolCallId: string; toolName: string; args: unknown }> - ).map((tc) => ({ - type: 'tool-call' as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.args, - })); - // Content includes text (if any) plus tool call parts - const content: Array< - | { type: 'text'; text: string } - | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } - > = []; - if (msg.content) { - content.push({ type: 'text', text: msg.content }); + ).filter((tc) => toolResultIds.has(tc.toolCallId)); + + if (validToolCalls.length > 0) { + const toolCallParts = validToolCalls.map((tc) => ({ + type: 'tool-call' as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: tc.args, + })); + // Content includes text (if any) plus tool call parts + const content: Array< + | { type: 'text'; text: string } + | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } + > = []; + if (msg.content) { + content.push({ type: 'text', text: msg.content }); + } + content.push(...toolCallParts); + messages.push({ + role: 'assistant', + content, + }); + } else { + // All tool calls are missing results, just include text content + messages.push({ + role: 'assistant', + content: msg.content || '', + }); } - content.push(...toolCallParts); - messages.push({ - role: 'assistant', - content, - }); } else { messages.push({ role: 'assistant', diff --git a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts index bf4d58f..37986df 100644 --- a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts @@ -274,6 +274,12 @@ export async function POST(request: NextRequest, context: RouteContext): Promise }); } + // Build a set of tool call IDs that have corresponding tool results + // This is needed because AI SDK requires every tool call to have a matching tool result + const toolResultIds = new Set( + recentMessages.filter((m) => m.role === 'TOOL' && m.toolCallId).map((m) => m.toolCallId) + ); + // Add conversation history - properly format for AI SDK for (const msg of recentMessages) { if (msg.role === 'USER') { @@ -281,27 +287,39 @@ export async function POST(request: NextRequest, context: RouteContext): Promise } else if (msg.role === 'ASSISTANT') { // For assistant messages with tool calls, include ToolCallParts in content if (msg.toolCalls && Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) { - const toolCallParts = ( + // Only include tool calls that have matching tool results + // AI SDK fails if there's a tool call without a corresponding tool result + const validToolCalls = ( msg.toolCalls as Array<{ toolCallId: string; toolName: string; args: unknown }> - ).map((tc) => ({ - type: 'tool-call' as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.args, - })); - // Content includes text (if any) plus tool call parts - const content: Array< - | { type: 'text'; text: string } - | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } - > = []; - if (msg.content) { - content.push({ type: 'text', text: msg.content }); + ).filter((tc) => toolResultIds.has(tc.toolCallId)); + + if (validToolCalls.length > 0) { + const toolCallParts = validToolCalls.map((tc) => ({ + type: 'tool-call' as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: tc.args, + })); + // Content includes text (if any) plus tool call parts + const content: Array< + | { type: 'text'; text: string } + | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } + > = []; + if (msg.content) { + content.push({ type: 'text', text: msg.content }); + } + content.push(...toolCallParts); + messages.push({ + role: 'assistant', + content, + }); + } else { + // All tool calls are missing results, just include text content + messages.push({ + role: 'assistant', + content: msg.content || '', + }); } - content.push(...toolCallParts); - messages.push({ - role: 'assistant', - content, - }); } else { messages.push({ role: 'assistant', diff --git a/apps/web/src/app/api/chat/[username]/[uid]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/chat/[username]/[uid]/conversation/[conversationId]/route.ts index 1cbc918..19eed01 100644 --- a/apps/web/src/app/api/chat/[username]/[uid]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/chat/[username]/[uid]/conversation/[conversationId]/route.ts @@ -206,6 +206,12 @@ export async function POST(request: NextRequest, context: RouteContext): Promise }); } + // Build a set of tool call IDs that have corresponding tool results + // This is needed because AI SDK requires every tool call to have a matching tool result + const toolResultIds = new Set( + recentMessages.filter((m) => m.role === 'TOOL' && m.toolCallId).map((m) => m.toolCallId) + ); + // Add conversation history - properly format for AI SDK for (const msg of recentMessages) { if (msg.role === 'USER') { @@ -213,27 +219,39 @@ export async function POST(request: NextRequest, context: RouteContext): Promise } else if (msg.role === 'ASSISTANT') { // For assistant messages with tool calls, include ToolCallParts in content if (msg.toolCalls && Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) { - const toolCallParts = ( + // Only include tool calls that have matching tool results + // AI SDK fails if there's a tool call without a corresponding tool result + const validToolCalls = ( msg.toolCalls as Array<{ toolCallId: string; toolName: string; args: unknown }> - ).map((tc) => ({ - type: 'tool-call' as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.args, - })); - // Content includes text (if any) plus tool call parts - const content: Array< - | { type: 'text'; text: string } - | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } - > = []; - if (msg.content) { - content.push({ type: 'text', text: msg.content }); + ).filter((tc) => toolResultIds.has(tc.toolCallId)); + + if (validToolCalls.length > 0) { + const toolCallParts = validToolCalls.map((tc) => ({ + type: 'tool-call' as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: tc.args, + })); + // Content includes text (if any) plus tool call parts + const content: Array< + | { type: 'text'; text: string } + | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } + > = []; + if (msg.content) { + content.push({ type: 'text', text: msg.content }); + } + content.push(...toolCallParts); + messages.push({ + role: 'assistant', + content, + }); + } else { + // All tool calls are missing results, just include text content + messages.push({ + role: 'assistant', + content: msg.content || '', + }); } - content.push(...toolCallParts); - messages.push({ - role: 'assistant', - content, - }); } else { messages.push({ role: 'assistant',