From 3c52f4742393b9762888039976de3bee476424ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 00:10:04 +0000 Subject: [PATCH] fix(ai): drop blank text from Anthropic error tool results An empty or whitespace-only text block in an is_error tool_result is a 400. Drop those items, and send a single [tool error] note when nothing remains. --- .../ai/src/protocols/anthropic-messages.ts | 32 +++++----- .../test/provider/anthropic-messages.test.ts | 64 +++++++++++++++++++ 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index dc2a972094ec..daa4b0476fe4 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -820,25 +820,23 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (item: Tool.Conte const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart, request: LLMRequest) { const rich = ProviderShared.toolErrorContent(part) - // Text / json / error results stay as a string for backward compatibility - // with existing cassettes and provider expectations. - if (part.result.type !== "content" && rich === undefined) return ProviderShared.toolResultText(part) - // Preserve the narrowed array element type when compiled through a consumer package. - const content: ReadonlyArray = part.result.type === "content" ? part.result.value : rich! - // An is_error tool_result may contain only text. Image and document blocks in that content are - // rejected, so replace them with a note instead of lowering media. - if (part.result.type === "error") return lowerToolErrorContent(part, content) - return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request)) + // An is_error tool_result may contain only text. Blank text is rejected, and image or document + // blocks are replaced with a note instead of lowering media. + if (part.result.type === "error") return lowerToolErrorContent(part, rich ?? []) + // Text and json results stay as a string for backward compatibility with existing cassettes. + if (part.result.type !== "content") return ProviderShared.toolResultText(part) + return yield* Effect.forEach(part.result.value, (item) => lowerToolResultContentItem(item, request)) }) -const lowerToolErrorContent = (part: ToolResultPart, content: ReadonlyArray) => [ - { type: "text" as const, text: ProviderShared.toolResultText(part) }, - ...content.map((item) => - item.type === "text" - ? { type: "text" as const, text: item.text } - : { type: "text" as const, text: omittedToolErrorNote(item) }, - ), -] +const lowerToolErrorContent = (part: ToolResultPart, content: ReadonlyArray) => { + const blocks = [ProviderShared.toolResultText(part), ...content.map(toolErrorItemText)].filter( + (text) => text.trim().length > 0, + ) + const text = blocks.length > 0 ? blocks : ["[tool error]"] + return text.map((item) => ({ type: "text" as const, text: item })) +} + +const toolErrorItemText = (item: Tool.Content) => (item.type === "text" ? item.text : omittedToolErrorNote(item)) const omittedToolErrorNote = (item: Tool.FileContent) => { if (item.mime.startsWith("image/")) return "[image omitted]" diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 853402e00c38..768d5db5bb5a 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -977,6 +977,70 @@ describe("Anthropic Messages route", () => { }), ) + const compileErrorToolResult = ( + value: unknown, + content?: ReadonlyArray<{ type: "text"; text: string } | { type: "file"; uri: string; mime: string }>, + ) => + compileRequest( + LLM.request({ + model, + cache: "none", + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "snapshot", input: {} })]), + Message.tool({ + id: "call_1", + name: "snapshot", + result: { type: "error", value, ...(content === undefined ? {} : { content }) }, + }), + ], + }), + ) + + const expectNoEmptyText = (content: AnthropicToolResult["content"]) => { + expect(Array.isArray(content)).toBe(true) + if (!Array.isArray(content)) return + expect(content.length).toBeGreaterThan(0) + for (const block of content) { + expect(block.type).toBe("text") + if (block.type === "text") expect(block.text.trim().length).toBeGreaterThan(0) + } + } + + it.effect("drops an empty text block from an empty-string error", () => + Effect.gen(function* () { + const prepared = yield* compileErrorToolResult("", [{ type: "text", text: "" }]) + const result = expectToolResult(prepared.body) + expect(result.is_error).toBe(true) + expect(result.content).toEqual([{ type: "text", text: "(no tool output)" }]) + expectNoEmptyText(result.content) + }), + ) + + it.effect("keeps an omission note when an error has only media", () => + Effect.gen(function* () { + const prepared = yield* compileErrorToolResult("", [ + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }, + ]) + const result = expectToolResult(prepared.body) + expect(result.is_error).toBe(true) + expect(result.content).toEqual([ + { type: "text", text: "(no tool output)" }, + { type: "text", text: "[image omitted]" }, + ]) + expectNoEmptyText(result.content) + }), + ) + + it.effect("drops whitespace-only error text", () => + Effect.gen(function* () { + const prepared = yield* compileErrorToolResult(" \n", [{ type: "text", text: "\t " }]) + const result = expectToolResult(prepared.body) + expect(result.is_error).toBe(true) + expect(result.content).toEqual([{ type: "text", text: "[tool error]" }]) + expectNoEmptyText(result.content) + }), + ) + const video = { type: "video", source: { type: "base64", media_type: "video/mp4", data: "AAECAw==" } } const videoFile = { type: "file" as const, uri: "data:video/mp4;base64,AAECAw==", mime: "video/mp4" } const customModel = AnthropicMessages.route.with({ provider: "custom" }).model({ id: "video-model" })