From 2161f6092b9ab817ea7fe57765c8664a570dc9bd Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 11 Feb 2026 15:45:48 -0500 Subject: [PATCH] fix: unify tool block handling across legacy and AI SDK formats --- src/core/condense/__tests__/index.spec.ts | 124 ++++++++++++++++++ src/core/condense/index.ts | 103 ++++++++++----- src/core/task/Task.ts | 61 +++++---- .../__tests__/validateToolResultIds.spec.ts | 52 ++++++++ src/core/task/toolBlockFormat.ts | 67 ++++++++++ src/core/task/validateToolResultIds.ts | 119 +++++++++++------ 6 files changed, 427 insertions(+), 99 deletions(-) create mode 100644 src/core/task/toolBlockFormat.ts diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 10092f71dc7..3149f712494 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -242,6 +242,53 @@ describe("injectSyntheticToolResults", () => { // Both tool_uses have matching tool_results, no injection needed expect(result).toEqual(messages) }) + + it("should support AI SDK tool-call/tool-result blocks", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", + input: { path: "test.ts" }, + }, + ] as any, + ts: 2, + }, + { + role: "user", + content: [{ type: "tool-result", toolCallId: "tool-1", output: "file contents" }] as any, + ts: 3, + }, + ] + + const result = injectSyntheticToolResults(messages) + expect(result).toEqual(messages) + }) + + it("should inject synthetic tool_result for orphan AI SDK tool-call", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "tool-orphan", toolName: "attempt_completion", input: {} }, + ] as any, + ts: 2, + }, + ] + + const result = injectSyntheticToolResults(messages) + + expect(result).toHaveLength(3) + const content = result[2].content as any[] + expect(content).toHaveLength(1) + expect(content[0].type).toBe("tool_result") + expect(content[0].tool_use_id).toBe("tool-orphan") + }) }) describe("getMessagesSinceLastSummary", () => { @@ -585,6 +632,58 @@ describe("getEffectiveApiHistory", () => { expect(userContent[0].type).toBe("text") expect(userContent[0].text).toBe("User added some text") }) + + it("should keep AI SDK tool-result blocks that have matching tool-call after summary", () => { + const condenseId = "cond-1" + const messages: ApiMessage[] = [ + { + role: "user", + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, + }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "tool-valid", toolName: "read_file", input: {} }] as any, + }, + { + role: "user", + content: [{ type: "tool-result", toolCallId: "tool-valid", output: "ok" }] as any, + }, + ] + + const result = getEffectiveApiHistory(messages) + + expect(result).toHaveLength(3) + expect((result[2].content as any[])[0].toolCallId).toBe("tool-valid") + }) + + it("should filter orphan AI SDK tool-result blocks after fresh start condensation", () => { + const condenseId = "cond-1" + const messages: ApiMessage[] = [ + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "tool-orphan", toolName: "attempt_completion", input: {} }, + ] as any, + condenseParent: condenseId, + }, + { + role: "user", + content: [{ type: "text", text: "Summary content" }], + isSummary: true, + condenseId, + }, + { + role: "user", + content: [{ type: "tool-result", toolCallId: "tool-orphan", output: "Rejected by user" }] as any, + }, + ] + + const result = getEffectiveApiHistory(messages) + expect(result).toHaveLength(1) + expect(result[0].isSummary).toBe(true) + }) }) describe("cleanupAfterTruncation", () => { @@ -1505,6 +1604,31 @@ describe("convertToolBlocksToText", () => { expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]") expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts") }) + + it("should convert AI SDK tool-call and tool-result blocks to text blocks", () => { + const content = [ + { + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", + input: { path: "a.ts" }, + }, + { + type: "tool-result", + toolCallId: "tool-1", + output: [{ type: "text", text: "contents of a.ts" }], + }, + ] as any + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + const resultArray = result as Anthropic.Messages.ContentBlockParam[] + expect(resultArray).toHaveLength(2) + expect((resultArray[0] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts") + }) }) describe("transformMessagesForCondensing", () => { diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 0438bf6bcb1..da80d269c3b 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -10,18 +10,55 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { RooIgnoreController } from "../ignore/RooIgnoreController" +import { + type ToolResultLikeBlock, + type ToolUseLikeBlock, + getToolResultLikeId, + getToolResultLikePayload, + getToolUseLikeId, + getToolUseLikeName, + isToolResultLikeBlock, + isToolUseLikeBlock, + stringifyUnknown, +} from "../task/toolBlockFormat" import { generateFoldedFileContext } from "./foldedFileContext" export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext" +function toolResultPayloadToText(payload: unknown): string { + if (typeof payload === "string") { + return payload + } + if (Array.isArray(payload)) { + return payload + .map((contentBlock: unknown) => { + if (!contentBlock || typeof contentBlock !== "object") { + return stringifyUnknown(contentBlock) + } + const block = contentBlock as { type?: string; text?: string; value?: unknown } + if (block.type === "text") { + return typeof block.text === "string" ? block.text : stringifyUnknown(block.value) + } + if (block.type === "image") { + return "[Image]" + } + return `[${block.type ?? "unknown"}]` + }) + .join("\n") + } + return stringifyUnknown(payload) +} + /** * Converts a tool_use block to a text representation. * This allows the conversation to be summarized without requiring the tools parameter. */ -export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): string { +export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam | ToolUseLikeBlock): string { + const toolName = getToolUseLikeName(block as ToolUseLikeBlock) + const toolInput = (block as ToolUseLikeBlock).input let input: string - if (typeof block.input === "object" && block.input !== null) { - input = Object.entries(block.input) + if (typeof toolInput === "object" && toolInput !== null) { + input = Object.entries(toolInput) .map(([key, value]) => { const formattedValue = typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) @@ -29,35 +66,21 @@ export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): stri }) .join("\n") } else { - input = String(block.input) + input = String(toolInput) } - return `[Tool Use: ${block.name}]\n${input}` + return `[Tool Use: ${toolName}]\n${input}` } /** * Converts a tool_result block to a text representation. * This allows the conversation to be summarized without requiring the tools parameter. */ -export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam): string { - const errorSuffix = block.is_error ? " (Error)" : "" - if (typeof block.content === "string") { - return `[Tool Result${errorSuffix}]\n${block.content}` - } else if (Array.isArray(block.content)) { - const contentText = block.content - .map((contentBlock) => { - if (contentBlock.type === "text") { - return contentBlock.text - } - if (contentBlock.type === "image") { - return "[Image]" - } - // Handle any other content block types - return `[${(contentBlock as { type: string }).type}]` - }) - .join("\n") - return `[Tool Result${errorSuffix}]\n${contentText}` - } - return `[Tool Result${errorSuffix}]` +export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam | ToolResultLikeBlock): string { + const isError = (block as ToolResultLikeBlock).is_error || (block as ToolResultLikeBlock).isError + const errorSuffix = isError ? " (Error)" : "" + const payload = getToolResultLikePayload(block as ToolResultLikeBlock) + const text = toolResultPayloadToText(payload) + return text ? `[Tool Result${errorSuffix}]\n${text}` : `[Tool Result${errorSuffix}]` } /** @@ -76,13 +99,13 @@ export function convertToolBlocksToText( } return content.map((block) => { - if (block.type === "tool_use") { + if (isToolUseLikeBlock(block)) { return { type: "text" as const, text: toolUseToText(block), } } - if (block.type === "tool_result") { + if (isToolResultLikeBlock(block)) { return { type: "text" as const, text: toolResultToText(block), @@ -140,15 +163,21 @@ export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] for (const msg of messages) { if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_use") { - toolCallIds.add(block.id) + if (isToolUseLikeBlock(block)) { + const id = getToolUseLikeId(block) + if (id) { + toolCallIds.add(id) + } } } } if (msg.role === "user" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_result") { - toolResultIds.add(block.tool_use_id) + if (isToolResultLikeBlock(block)) { + const id = getToolResultLikeId(block) + if (id) { + toolResultIds.add(id) + } } } } @@ -559,8 +588,11 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { for (const msg of messagesFromSummary) { if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) { - toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id) + if (isToolUseLikeBlock(block)) { + const id = getToolUseLikeId(block) + if (id) { + toolUseIds.add(id) + } } } } @@ -571,8 +603,9 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { .map((msg) => { if (msg.role === "user" && Array.isArray(msg.content)) { const filteredContent = msg.content.filter((block) => { - if (block.type === "tool_result") { - return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id) + if (isToolResultLikeBlock(block)) { + const id = getToolResultLikeId(block) + return id ? toolUseIds.has(id) : false } return true }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9d27c4b90b0..72d7ccf516d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -134,12 +134,25 @@ import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval" import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" +import { + type ToolResultLikeBlock, + getToolResultLikeId, + getToolResultLikePayload, + getToolUseLikeId, + isToolResultLikeBlock, + isToolUseLikeBlock, + stringifyUnknown, +} from "./toolBlockFormat" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +function toolResultPayloadToString(block: ToolResultLikeBlock): string { + return stringifyUnknown(getToolResultLikePayload(block)) +} + export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider apiConfiguration: ProviderSettings @@ -1168,10 +1181,10 @@ export class Task extends EventEmitter implements TaskLike { messageToAdd = { ...message, content: message.content.map((block) => - block.type === "tool_result" + isToolResultLikeBlock(block) ? { type: "text" as const, - text: `Tool result:\n${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`, + text: `Tool result:\n${toolResultPayloadToString(block)}`, } : block, ), @@ -2279,17 +2292,17 @@ export class Task extends EventEmitter implements TaskLike { const content = Array.isArray(lastMessage.content) ? lastMessage.content : [{ type: "text", text: lastMessage.content }] - const hasToolUse = content.some((block) => block.type === "tool_use") + const hasToolUse = content.some(isToolUseLikeBlock) if (hasToolUse) { - const toolUseBlocks = content.filter( - (block) => block.type === "tool_use", - ) as Anthropic.Messages.ToolUseBlock[] - const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({ - type: "tool_result", - tool_use_id: block.id, - content: "Task was interrupted before this tool call could be completed.", - })) + const toolResponses: Anthropic.ToolResultBlockParam[] = content + .map((block) => (isToolUseLikeBlock(block) ? getToolUseLikeId(block) : undefined)) + .filter((id): id is string => Boolean(id)) + .map((toolUseId) => ({ + type: "tool_result", + tool_use_id: toolUseId, + content: "Task was interrupted before this tool call could be completed.", + })) modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes modifiedOldUserContent = [...toolResponses] } else { @@ -2308,22 +2321,22 @@ export class Task extends EventEmitter implements TaskLike { ? previousAssistantMessage.content : [{ type: "text", text: previousAssistantMessage.content }] - const toolUseBlocks = assistantContent.filter( - (block) => block.type === "tool_use", - ) as Anthropic.Messages.ToolUseBlock[] + const toolUseIds = assistantContent + .map((block) => (isToolUseLikeBlock(block) ? getToolUseLikeId(block) : undefined)) + .filter((id): id is string => Boolean(id)) - if (toolUseBlocks.length > 0) { - const existingToolResults = existingUserContent.filter( - (block) => block.type === "tool_result", - ) as Anthropic.ToolResultBlockParam[] + if (toolUseIds.length > 0) { + const existingToolResultIds = new Set( + existingUserContent + .map((block) => (isToolResultLikeBlock(block) ? getToolResultLikeId(block) : undefined)) + .filter((id): id is string => Boolean(id)), + ) - const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks - .filter( - (toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id), - ) - .map((toolUse) => ({ + const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseIds + .filter((toolUseId) => !existingToolResultIds.has(toolUseId)) + .map((toolUseId) => ({ type: "tool_result", - tool_use_id: toolUse.id, + tool_use_id: toolUseId, content: "Task was interrupted before this tool call could be completed.", })) diff --git a/src/core/task/__tests__/validateToolResultIds.spec.ts b/src/core/task/__tests__/validateToolResultIds.spec.ts index 0926e899aad..4702cd6b74c 100644 --- a/src/core/task/__tests__/validateToolResultIds.spec.ts +++ b/src/core/task/__tests__/validateToolResultIds.spec.ts @@ -231,6 +231,58 @@ describe("validateAndFixToolResultIds", () => { }) }) + describe("AI SDK tool-call/tool-result format", () => { + it("should keep matching toolCallId unchanged", () => { + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: {} }] as any, + } + + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [{ type: "tool-result", toolCallId: "call-1", output: "ok" }] as any, + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + expect(result).toEqual(userMessage) + }) + + it("should fix mismatched toolCallId by position", () => { + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-correct", toolName: "read_file", input: {} }] as any, + } + + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [{ type: "tool-result", toolCallId: "call-wrong", output: "ok" }] as any, + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + const resultContent = result.content as any[] + expect(resultContent[0].type).toBe("tool-result") + expect(resultContent[0].toolCallId).toBe("call-correct") + }) + + it("should inject missing tool-result in AI SDK format when assistant used tool-call", () => { + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: {} }] as any, + } + + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [{ type: "text", text: "No tool result yet" }], + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + const resultContent = result.content as any[] + expect(resultContent[0].type).toBe("tool-result") + expect(resultContent[0].toolCallId).toBe("call-1") + expect(resultContent[0].output).toBe("Tool execution was interrupted before completion.") + }) + }) + describe("when user message has non-tool_result content", () => { it("should preserve text blocks alongside tool_result blocks", () => { const assistantMessage: Anthropic.MessageParam = { diff --git a/src/core/task/toolBlockFormat.ts b/src/core/task/toolBlockFormat.ts new file mode 100644 index 00000000000..3419526fc2e --- /dev/null +++ b/src/core/task/toolBlockFormat.ts @@ -0,0 +1,67 @@ +export type ToolUseLikeBlock = { + type: "tool_use" | "tool-call" + id?: string + toolCallId?: string + name?: string + toolName?: string + input?: unknown +} + +export type ToolResultLikeBlock = { + type: "tool_result" | "tool-result" + tool_use_id?: string + toolCallId?: string + content?: unknown + output?: unknown + is_error?: boolean + isError?: boolean +} + +export function isToolUseLikeBlock(block: unknown): block is ToolUseLikeBlock { + if (!block || typeof block !== "object") { + return false + } + const maybeTool = block as ToolUseLikeBlock + return maybeTool.type === "tool_use" || maybeTool.type === "tool-call" +} + +export function getToolUseLikeId(block: ToolUseLikeBlock): string | undefined { + return block.type === "tool_use" ? block.id : block.toolCallId +} + +export function getToolUseLikeName(block: ToolUseLikeBlock): string { + return block.type === "tool_use" ? (block.name ?? "unknown_tool") : (block.toolName ?? "unknown_tool") +} + +export function isToolResultLikeBlock(block: unknown): block is ToolResultLikeBlock { + if (!block || typeof block !== "object") { + return false + } + const maybeToolResult = block as ToolResultLikeBlock + return maybeToolResult.type === "tool_result" || maybeToolResult.type === "tool-result" +} + +export function getToolResultLikeId(block: ToolResultLikeBlock): string | undefined { + return block.type === "tool_result" ? block.tool_use_id : block.toolCallId +} + +export function getToolResultLikePayload(block: ToolResultLikeBlock): unknown { + return block.type === "tool_result" ? block.content : block.output +} + +export function stringifyUnknown(value: unknown): string { + if (typeof value === "string") { + return value + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value) + } + if (value === null || value === undefined) { + return "" + } + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} diff --git a/src/core/task/validateToolResultIds.ts b/src/core/task/validateToolResultIds.ts index a966d429ed5..0722241ca2f 100644 --- a/src/core/task/validateToolResultIds.ts +++ b/src/core/task/validateToolResultIds.ts @@ -1,6 +1,9 @@ import { Anthropic } from "@anthropic-ai/sdk" import { TelemetryService } from "@roo-code/telemetry" import { findLastIndex } from "../../shared/array" +import { getToolResultLikeId, getToolUseLikeId, isToolResultLikeBlock, isToolUseLikeBlock } from "./toolBlockFormat" + +type AnyContentBlock = Anthropic.Messages.ContentBlockParam | (Record & { type: string }) /** * Custom error class for tool result ID mismatches. @@ -51,8 +54,10 @@ export function validateAndFixToolResultIds( userMessage: Anthropic.MessageParam, apiConversationHistory: Anthropic.MessageParam[], ): Anthropic.MessageParam { + const userContent = userMessage.content as AnyContentBlock[] + // Only process user messages with array content - if (userMessage.role !== "user" || !Array.isArray(userMessage.content)) { + if (userMessage.role !== "user" || !Array.isArray(userContent)) { return userMessage } @@ -70,7 +75,7 @@ export function validateAndFixToolResultIds( return userMessage } - const toolUseBlocks = assistantContent.filter((block): block is Anthropic.ToolUseBlock => block.type === "tool_use") + const toolUseBlocks = (assistantContent as AnyContentBlock[]).filter(isToolUseLikeBlock) // No tool_use blocks to match against - no validation needed if (toolUseBlocks.length === 0) { @@ -78,9 +83,7 @@ export function validateAndFixToolResultIds( } // Find tool_result blocks in the user message - let toolResults = userMessage.content.filter( - (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", - ) + let toolResults = userContent.filter(isToolResultLikeBlock) // Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465) // This serves as a safety net for any potential race conditions that could generate @@ -88,39 +91,49 @@ export function validateAndFixToolResultIds( // creating duplicate results) has been fixed in presentAssistantMessage.ts, but this // deduplication remains as a defensive measure for unknown edge cases. const seenToolResultIds = new Set() - const deduplicatedContent = userMessage.content.filter((block) => { - if (block.type !== "tool_result") { + const deduplicatedContent = userContent.filter((block) => { + if (!isToolResultLikeBlock(block)) { return true } - if (seenToolResultIds.has(block.tool_use_id)) { + const id = getToolResultLikeId(block) + if (!id) { + return false + } + if (seenToolResultIds.has(id)) { return false // Duplicate - filter out } - seenToolResultIds.add(block.tool_use_id) + seenToolResultIds.add(id) return true }) userMessage = { ...userMessage, - content: deduplicatedContent, + content: deduplicatedContent as Anthropic.Messages.ContentBlockParam[], } - toolResults = deduplicatedContent.filter( - (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", - ) + toolResults = deduplicatedContent.filter(isToolResultLikeBlock) // Build a set of valid tool_use IDs - const validToolUseIds = new Set(toolUseBlocks.map((block) => block.id)) + const validToolUseIds = new Set( + toolUseBlocks.map((block) => getToolUseLikeId(block)).filter((id): id is string => Boolean(id)), + ) // Build a set of existing tool_result IDs - const existingToolResultIds = new Set(toolResults.map((r) => r.tool_use_id)) + const existingToolResultIds = new Set( + toolResults.map((result) => getToolResultLikeId(result)).filter((id): id is string => Boolean(id)), + ) // Check for missing tool_results (tool_use IDs that don't have corresponding tool_results) const missingToolUseIds = toolUseBlocks - .filter((toolUse) => !existingToolResultIds.has(toolUse.id)) - .map((toolUse) => toolUse.id) + .map((block) => getToolUseLikeId(block)) + .filter((id): id is string => Boolean(id)) + .filter((id) => !existingToolResultIds.has(id)) // Check if any tool_result has an invalid ID - const hasInvalidIds = toolResults.some((result) => !validToolUseIds.has(result.tool_use_id)) + const hasInvalidIds = toolResults.some((result) => { + const id = getToolResultLikeId(result) + return !id || !validToolUseIds.has(id) + }) // If no missing tool_results and no invalid IDs, no changes needed if (missingToolUseIds.length === 0 && !hasInvalidIds) { @@ -128,8 +141,12 @@ export function validateAndFixToolResultIds( } // We have issues - need to fix them - const toolResultIdList = toolResults.map((r) => r.tool_use_id) - const toolUseIdList = toolUseBlocks.map((b) => b.id) + const toolResultIdList = toolResults + .map((result) => getToolResultLikeId(result)) + .filter((id): id is string => Boolean(id)) + const toolUseIdList = toolUseBlocks + .map((toolUse) => getToolUseLikeId(toolUse)) + .filter((id): id is string => Boolean(id)) // Report missing tool_results to PostHog error tracking if (missingToolUseIds.length > 0 && TelemetryService.hasInstance()) { @@ -167,34 +184,44 @@ export function validateAndFixToolResultIds( // Match tool_results to tool_uses by position and fix incorrect IDs const usedToolUseIds = new Set() - const contentArray = userMessage.content as Anthropic.Messages.ContentBlockParam[] + const contentArray = userMessage.content as AnyContentBlock[] const correctedContent = contentArray - .map((block: Anthropic.Messages.ContentBlockParam) => { - if (block.type !== "tool_result") { + .map((block: AnyContentBlock) => { + if (!isToolResultLikeBlock(block)) { return block } + const currentId = getToolResultLikeId(block) // If the ID is already valid and not yet used, keep it - if (validToolUseIds.has(block.tool_use_id) && !usedToolUseIds.has(block.tool_use_id)) { - usedToolUseIds.add(block.tool_use_id) + if (currentId && validToolUseIds.has(currentId) && !usedToolUseIds.has(currentId)) { + usedToolUseIds.add(currentId) return block } // Find which tool_result index this block is by comparing references. // This correctly handles duplicate tool_use_ids - we find the actual block's // position among all tool_results, not the first block with a matching ID. - const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam) + const toolResultIndex = toolResults.indexOf(block) // Try to match by position - only fix if there's a corresponding tool_use if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) { - const correctId = toolUseBlocks[toolResultIndex].id + const correctId = getToolUseLikeId(toolUseBlocks[toolResultIndex]) + if (!correctId) { + return null + } // Only use this ID if it hasn't been used yet if (!usedToolUseIds.has(correctId)) { usedToolUseIds.add(correctId) + if (block.type === "tool_result") { + return { + ...block, + tool_use_id: correctId, + } + } return { ...block, - tool_use_id: correctId, + toolCallId: correctId, } } } @@ -207,21 +234,33 @@ export function validateAndFixToolResultIds( // Add missing tool_result blocks for any tool_use that doesn't have one const coveredToolUseIds = new Set( correctedContent - .filter( - (b: Anthropic.Messages.ContentBlockParam): b is Anthropic.ToolResultBlockParam => - b.type === "tool_result", - ) - .map((r: Anthropic.ToolResultBlockParam) => r.tool_use_id), + .filter(isToolResultLikeBlock) + .map((result) => getToolResultLikeId(result)) + .filter((id): id is string => Boolean(id)), ) - const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id)) + const stillMissingToolUseIds = toolUseBlocks + .map((toolUse) => getToolUseLikeId(toolUse)) + .filter((id): id is string => Boolean(id)) + .filter((id) => !coveredToolUseIds.has(id)) // Build final content: add missing tool_results at the beginning if any - const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({ - type: "tool_result" as const, - tool_use_id: toolUse.id, - content: "Tool execution was interrupted before completion.", - })) + const prefersAiSdkToolResultFormat = + toolResults.some((result) => result.type === "tool-result") || + toolUseBlocks.some((toolUse) => toolUse.type === "tool-call") + const missingToolResults = stillMissingToolUseIds.map((toolUseId) => + prefersAiSdkToolResultFormat + ? ({ + type: "tool-result" as const, + toolCallId: toolUseId, + output: "Tool execution was interrupted before completion.", + } satisfies AnyContentBlock) + : ({ + type: "tool_result" as const, + tool_use_id: toolUseId, + content: "Tool execution was interrupted before completion.", + } satisfies AnyContentBlock), + ) // Insert missing tool_results at the beginning of the content array // This ensures they come before any text blocks that may summarize the results @@ -229,6 +268,6 @@ export function validateAndFixToolResultIds( return { ...userMessage, - content: finalContent, + content: finalContent as Anthropic.Messages.ContentBlockParam[], } }