fix: handle tool_use and tool_result blocks in convertToR1Format

When using deepseek-reasoner with Native tool call protocol, the
convertToR1Format function was stripping all tool information from
conversation history, causing the model to repeat the first step.

This fix:
- Converts assistant tool_use blocks to OpenAI tool_calls format
- Converts user tool_result blocks to OpenAI tool role messages
- Maintains existing message merging behavior while properly handling
  tool-related blocks
- Adds comprehensive tests for tool handling scenarios

Fixes #10128
This commit is contained in:
Roo Code 2025-12-16 12:52:28 +00:00
parent 596783d365
commit 50689c4d61
2 changed files with 449 additions and 67 deletions

View file

@ -179,4 +179,276 @@ describe("convertToR1Format", () => {
expect(convertToR1Format(input)).toEqual(expected)
})
describe("tool handling", () => {
it("should handle assistant messages with tool_use blocks", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "Let me read that file for you.",
},
{
type: "tool_use",
id: "tool-123",
name: "read_file",
input: { path: "/path/to/file.txt" },
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
const assistantMessage = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMessage.role).toBe("assistant")
expect(assistantMessage.content).toBe("Let me read that file for you.")
expect(assistantMessage.tool_calls).toHaveLength(1)
expect(assistantMessage.tool_calls![0]).toEqual({
id: "tool-123",
type: "function",
function: {
name: "read_file",
arguments: JSON.stringify({ path: "/path/to/file.txt" }),
},
})
})
it("should handle user messages with tool_result blocks", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-123",
content: "File contents here",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
const toolMessage = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMessage.role).toBe("tool")
expect(toolMessage.tool_call_id).toBe("tool-123")
expect(toolMessage.content).toBe("File contents here")
})
it("should handle multiple tool_use blocks in one assistant message", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "I'll read both files.",
},
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "/file1.txt" },
},
{
type: "tool_use",
id: "tool-2",
name: "read_file",
input: { path: "/file2.txt" },
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
const assistantMessage = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMessage.tool_calls).toHaveLength(2)
expect(assistantMessage.tool_calls![0].id).toBe("tool-1")
expect(assistantMessage.tool_calls![1].id).toBe("tool-2")
})
it("should handle multiple tool_result blocks in one user message", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "Content of file 1",
},
{
type: "tool_result",
tool_use_id: "tool-2",
content: "Content of file 2",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(2)
const toolMessage1 = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMessage1.role).toBe("tool")
expect(toolMessage1.tool_call_id).toBe("tool-1")
expect(toolMessage1.content).toBe("Content of file 1")
const toolMessage2 = result[1] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMessage2.role).toBe("tool")
expect(toolMessage2.tool_call_id).toBe("tool-2")
expect(toolMessage2.content).toBe("Content of file 2")
})
it("should handle full tool conversation flow", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Read the file at /test.txt" },
{
role: "assistant",
content: [
{
type: "text",
text: "I'll read that file for you.",
},
{
type: "tool_use",
id: "tool-abc",
name: "read_file",
input: { path: "/test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-abc",
content: "Hello, World!",
},
],
},
{
role: "assistant",
content: "The file contains: Hello, World!",
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(4)
// First user message
expect(result[0]).toEqual({ role: "user", content: "Read the file at /test.txt" })
// Assistant with tool call
const assistantWithTool = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantWithTool.role).toBe("assistant")
expect(assistantWithTool.content).toBe("I'll read that file for you.")
expect(assistantWithTool.tool_calls).toHaveLength(1)
// Tool result
const toolResult = result[2] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolResult.role).toBe("tool")
expect(toolResult.tool_call_id).toBe("tool-abc")
expect(toolResult.content).toBe("Hello, World!")
// Final assistant response
expect(result[3]).toEqual({ role: "assistant", content: "The file contains: Hello, World!" })
})
it("should handle tool_result with array content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-xyz",
content: [
{ type: "text", text: "Line 1" },
{ type: "text", text: "Line 2" },
],
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
const toolMessage = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMessage.content).toBe("Line 1\nLine 2")
})
it("should handle mixed tool_result and text content in user message", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-123",
content: "Tool output",
},
{
type: "text",
text: "Here's some additional context",
},
],
},
]
const result = convertToR1Format(input)
// Should have tool message first, then user message
expect(result).toHaveLength(2)
const toolMessage = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMessage.role).toBe("tool")
expect(toolMessage.content).toBe("Tool output")
const userMessage = result[1] as OpenAI.Chat.ChatCompletionUserMessageParam
expect(userMessage.role).toBe("user")
expect(userMessage.content).toBe("Here's some additional context")
})
it("should not merge assistant messages with tool_calls", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "First action",
},
{
type: "tool_use",
id: "tool-1",
name: "action_1",
input: {},
},
],
},
{
role: "assistant",
content: "Second message",
},
]
const result = convertToR1Format(input)
// Should NOT be merged because first message has tool_calls
expect(result).toHaveLength(2)
const firstAssistant = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(firstAssistant.tool_calls).toHaveLength(1)
expect(result[1]).toEqual({ role: "assistant", content: "Second message" })
})
})
})

View file

@ -5,6 +5,7 @@ type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText
type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage
type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam
type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam
type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam
type Message = OpenAI.Chat.ChatCompletionMessageParam
type AnthropicMessage = Anthropic.Messages.MessageParam
@ -16,83 +17,192 @@ type AnthropicMessage = Anthropic.Messages.MessageParam
* @returns Array of OpenAI messages where consecutive messages with the same role are combined
*/
export function convertToR1Format(messages: AnthropicMessage[]): Message[] {
return messages.reduce<Message[]>((merged, message) => {
const lastMessage = merged[merged.length - 1]
let messageContent: string | (ContentPartText | ContentPartImage)[] = ""
let hasImages = false
const result: Message[] = []
// Convert content to appropriate format
for (const message of messages) {
// Handle array content (may contain tool_use, tool_result, text, image)
if (Array.isArray(message.content)) {
const textParts: string[] = []
const imageParts: ContentPartImage[] = []
if (message.role === "user") {
// Separate tool_result blocks from other content
const toolResults: Anthropic.ToolResultBlockParam[] = []
const nonToolContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text)
message.content.forEach((part) => {
if (part.type === "tool_result") {
toolResults.push(part)
} else if (part.type === "text" || part.type === "image") {
nonToolContent.push(part)
}
})
// Process tool result messages - these become "tool" role messages
toolResults.forEach((toolResult) => {
let content: string
if (typeof toolResult.content === "string") {
content = toolResult.content
} else {
content =
toolResult.content
?.map((part) => {
if (part.type === "image") {
return "(see following user message for image)"
}
return part.text
})
.join("\n") ?? ""
}
const toolMessage: ToolMessage = {
role: "tool",
tool_call_id: toolResult.tool_use_id,
content: content,
}
result.push(toolMessage)
})
// Process non-tool content
if (nonToolContent.length > 0) {
const messageContent = convertUserContent(nonToolContent)
addOrMergeMessage(result, "user", messageContent)
}
if (part.type === "image") {
hasImages = true
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})
} else if (message.role === "assistant") {
// Separate tool_use blocks from other content
const toolUses: Anthropic.ToolUseBlockParam[] = []
const nonToolContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
message.content.forEach((part) => {
if (part.type === "tool_use") {
toolUses.push(part)
} else if (part.type === "text" || part.type === "image") {
nonToolContent.push(part)
}
})
// Build assistant message with optional tool_calls
let textContent: string | undefined
if (nonToolContent.length > 0) {
textContent = nonToolContent
.map((part) => {
if (part.type === "image") {
return "" // assistant cannot send images
}
return part.text
})
.join("\n")
}
})
if (hasImages) {
const parts: (ContentPartText | ContentPartImage)[] = []
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") })
}
parts.push(...imageParts)
messageContent = parts
} else {
messageContent = textParts.join("\n")
}
} else {
messageContent = message.content
}
const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined =
toolUses.length > 0
? toolUses.map((toolUse) => ({
id: toolUse.id,
type: "function" as const,
function: {
name: toolUse.name,
arguments: JSON.stringify(toolUse.input),
},
}))
: undefined
// If last message has same role, merge the content
if (lastMessage?.role === message.role) {
if (typeof lastMessage.content === "string" && typeof messageContent === "string") {
lastMessage.content += `\n${messageContent}`
}
// If either has image content, convert both to array format
else {
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }]
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }]
if (message.role === "assistant") {
const mergedContent = [...lastContent, ...newContent] as AssistantMessage["content"]
lastMessage.content = mergedContent
} else {
const mergedContent = [...lastContent, ...newContent] as UserMessage["content"]
lastMessage.content = mergedContent
// If we have tool_calls, we can't merge with previous message
if (toolCalls) {
const assistantMessage: AssistantMessage = {
role: "assistant",
content: textContent,
tool_calls: toolCalls,
}
result.push(assistantMessage)
} else if (textContent !== undefined) {
addOrMergeMessage(result, "assistant", textContent)
}
}
} else {
// Add as new message with the correct type based on role
if (message.role === "assistant") {
const newMessage: AssistantMessage = {
role: "assistant",
content: messageContent as AssistantMessage["content"],
}
merged.push(newMessage)
} else {
const newMessage: UserMessage = {
role: "user",
content: messageContent as UserMessage["content"],
}
merged.push(newMessage)
}
// Simple string content
addOrMergeMessage(result, message.role, message.content)
}
}
return merged
}, [])
return result
}
/**
* Converts Anthropic user content blocks to OpenAI format
*/
function convertUserContent(
content: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[],
): string | (ContentPartText | ContentPartImage)[] {
const textParts: string[] = []
const imageParts: ContentPartImage[] = []
let hasImages = false
content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text)
}
if (part.type === "image") {
hasImages = true
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})
}
})
if (hasImages) {
const parts: (ContentPartText | ContentPartImage)[] = []
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") })
}
parts.push(...imageParts)
return parts
}
return textParts.join("\n")
}
/**
* Adds a message to the result array, merging with the last message if roles match
*/
function addOrMergeMessage(
result: Message[],
role: "user" | "assistant",
content: string | (ContentPartText | ContentPartImage)[],
): void {
const lastMessage = result[result.length - 1]
// Can only merge if last message has same role and no tool_calls
if (lastMessage?.role === role && !("tool_calls" in lastMessage && lastMessage.tool_calls)) {
if (typeof lastMessage.content === "string" && typeof content === "string") {
lastMessage.content += `\n${content}`
} else {
// If either has image content, convert both to array format
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }]
const newContent = Array.isArray(content) ? content : [{ type: "text" as const, text: content }]
if (role === "assistant") {
const mergedContent = [...lastContent, ...newContent] as AssistantMessage["content"]
lastMessage.content = mergedContent
} else {
const mergedContent = [...lastContent, ...newContent] as UserMessage["content"]
lastMessage.content = mergedContent
}
}
} else {
// Add as new message
if (role === "assistant") {
const newMessage: AssistantMessage = {
role: "assistant",
content: content as AssistantMessage["content"],
}
result.push(newMessage)
} else {
const newMessage: UserMessage = {
role: "user",
content: content as UserMessage["content"],
}
result.push(newMessage)
}
}
}