From a40b4f0c96f57ae9e5386acfb63f2b6792acad49 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 26 Jan 2026 11:21:17 -0500 Subject: [PATCH] fix: transform tool blocks to text during condensing for Bedrock compatibility - Add toolUseToText() to convert tool_use blocks to text format - Add toolResultToText() to convert tool_result blocks to text format - Add convertToolBlocksToText() to transform all tool blocks in message content - Add transformMessagesForCondensing() to apply transformation to messages - Apply transformation in summarizeConversation() before API call - Add 18 unit tests for full coverage Fixes LiteLLM/Bedrock error: 'Bedrock doesn't support tool calling without tools= param specified' When condensing conversations containing tool_use/tool_result blocks, Bedrock requires the tools parameter. By transforming these blocks to text representations, we remove this dependency while preserving semantic meaning for summarization. --- src/core/condense/__tests__/index.spec.ts | 307 ++++++++++++++++++++++ src/core/condense/index.ts | 104 +++++++- 2 files changed, 409 insertions(+), 2 deletions(-) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 8a0f3bea63a..b132e281030 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -15,6 +15,10 @@ import { cleanupAfterTruncation, extractCommandBlocks, injectSyntheticToolResults, + toolUseToText, + toolResultToText, + convertToolBlocksToText, + transformMessagesForCondensing, } from "../index" vi.mock("../../../api/transform/image-cleaning", () => ({ @@ -1218,3 +1222,306 @@ describe("summarizeConversation with custom settings", () => { ) }) }) + +describe("toolUseToText", () => { + it("should convert tool_use block with object input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts", encoding: "utf-8" }, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: read_file]\npath: test.ts\nencoding: utf-8") + }) + + it("should convert tool_use block with nested object input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-456", + name: "write_file", + input: { + path: "output.json", + content: { key: "value", nested: { a: 1 } }, + }, + } + + const result = toolUseToText(block) + + expect(result).toContain("[Tool Use: write_file]") + expect(result).toContain("path: output.json") + expect(result).toContain("content:") + expect(result).toContain('"key"') + expect(result).toContain('"value"') + }) + + it("should convert tool_use block with string input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-789", + name: "execute_command", + input: "ls -la" as unknown as Record, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: execute_command]\nls -la") + }) + + it("should handle empty object input", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-empty", + name: "some_tool", + input: {}, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: some_tool]\n") + }) +}) + +describe("toolResultToText", () => { + it("should convert tool_result with string content to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents here", + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nFile contents here") + }) + + it("should convert tool_result with error flag to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-456", + content: "File not found", + is_error: true, + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result (Error)]\nFile not found") + }) + + it("should convert tool_result with array content to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-789", + content: [ + { type: "text", text: "First line" }, + { type: "text", text: "Second line" }, + ], + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nFirst line\nSecond line") + }) + + it("should handle tool_result with image in array content", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-img", + content: [ + { type: "text", text: "Screenshot:" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "abc123" } }, + ], + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nScreenshot:\n[Image]") + }) + + it("should handle tool_result with no content", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-empty", + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]") + }) +}) + +describe("convertToolBlocksToText", () => { + it("should return string content unchanged", () => { + const content = "Simple text content" + + const result = convertToolBlocksToText(content) + + expect(result).toBe("Simple text content") + }) + + it("should convert tool_use blocks to text blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts" }, + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Use: read_file]") + }) + + it("should convert tool_result blocks to text blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents", + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Result]") + }) + + it("should preserve non-tool blocks unchanged", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: "Hello" }, + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts" }, + }, + { type: "text", text: "World" }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + const resultArray = result as Anthropic.Messages.ContentBlockParam[] + expect(resultArray).toHaveLength(3) + expect(resultArray[0]).toEqual({ type: "text", text: "Hello" }) + expect(resultArray[1].type).toBe("text") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect(resultArray[2]).toEqual({ type: "text", text: "World" }) + }) + + it("should handle mixed content with multiple tool blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "a.ts" }, + }, + { + type: "tool_result", + tool_use_id: "tool-1", + content: "contents of a.ts", + }, + ] + + 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", () => { + it("should transform all messages with tool blocks to text", () => { + const messages = [ + { role: "user" as const, content: "Hello" }, + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "tool-1", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + tool_use_id: "tool-1", + content: "file contents", + }, + ], + }, + ] + + const result = transformMessagesForCondensing(messages) + + expect(result).toHaveLength(3) + expect(result[0].content).toBe("Hello") + expect(Array.isArray(result[1].content)).toBe(true) + expect((result[1].content as any[])[0].type).toBe("text") + expect((result[1].content as any[])[0].text).toContain("[Tool Use: read_file]") + expect(Array.isArray(result[2].content)).toBe(true) + expect((result[2].content as any[])[0].type).toBe("text") + expect((result[2].content as any[])[0].text).toContain("[Tool Result]") + }) + + it("should preserve message role and other properties", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "tool-1", + name: "execute", + input: { cmd: "ls" }, + }, + ], + }, + ] + + const result = transformMessagesForCondensing(messages) + + expect(result[0].role).toBe("assistant") + }) + + it("should handle empty messages array", () => { + const result = transformMessagesForCondensing([]) + + expect(result).toEqual([]) + }) + + it("should not mutate original messages", () => { + const originalContent = [ + { + type: "tool_use" as const, + id: "tool-1", + name: "read_file", + input: { path: "test.ts" }, + }, + ] + const messages = [{ role: "assistant" as const, content: originalContent }] + + transformMessagesForCondensing(messages) + + // Original should still have tool_use type + expect(messages[0].content[0].type).toBe("tool_use") + }) +}) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 0c92087aff1..9b41130b41a 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -10,6 +10,100 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" +/** + * 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 { + let input: string + if (typeof block.input === "object" && block.input !== null) { + input = Object.entries(block.input) + .map(([key, value]) => { + const formattedValue = + typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) + return `${key}: ${formattedValue}` + }) + .join("\n") + } else { + input = String(block.input) + } + return `[Tool Use: ${block.name}]\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}]` +} + +/** + * Converts all tool_use and tool_result blocks in a message's content to text representations. + * This is necessary for providers like Bedrock that require the tools parameter when tool blocks are present. + * By converting to text, we can send the conversation for summarization without the tools parameter. + * + * @param content - The message content (string or array of content blocks) + * @returns The transformed content with tool blocks converted to text blocks + */ +export function convertToolBlocksToText( + content: string | Anthropic.Messages.ContentBlockParam[], +): string | Anthropic.Messages.ContentBlockParam[] { + if (typeof content === "string") { + return content + } + + return content.map((block) => { + if (block.type === "tool_use") { + return { + type: "text" as const, + text: toolUseToText(block), + } + } + if (block.type === "tool_result") { + return { + type: "text" as const, + text: toolResultToText(block), + } + } + return block + }) +} + +/** + * Transforms all messages by converting tool_use and tool_result blocks to text representations. + * This ensures the conversation can be sent for summarization without requiring the tools parameter. + * + * @param messages - The messages to transform + * @returns The transformed messages with tool blocks converted to text + */ +export function transformMessagesForCondensing< + T extends { role: string; content: string | Anthropic.Messages.ContentBlockParam[] }, +>(messages: T[]): T[] { + return messages.map((msg) => ({ + ...msg, + content: convertToolBlocksToText(msg.content), + })) +} + export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing @@ -200,10 +294,16 @@ export async function summarizeConversation( // (e.g., when user triggers condense after receiving attempt_completion but before responding) const messagesWithToolResults = injectSyntheticToolResults(messagesToSummarize) - const requestMessages = maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler).map( - ({ role, content }) => ({ role, content }), + // Transform tool_use and tool_result blocks to text representations. + // This is necessary because some providers (like Bedrock via LiteLLM) require the `tools` parameter + // when tool blocks are present. By converting them to text, we can send the conversation for + // summarization without needing to pass the tools parameter. + const messagesWithTextToolBlocks = transformMessagesForCondensing( + maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler), ) + const requestMessages = messagesWithTextToolBlocks.map(({ role, content }) => ({ role, content })) + // Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt const promptToUse = SUMMARY_PROMPT