fix: handle tool_use/tool_result blocks in r1-format for DeepSeek Reasoner

This fixes issue #10101 where DeepSeek Reasoner (and other models using
R1 format) would get stuck in a loop repeatedly calling update_todo_list.

The root cause was that convertToR1Format did not handle tool_use and
tool_result blocks. When native tools were used:
- tool_use blocks in assistant messages were being ignored
- tool_result blocks in user messages were being ignored

This caused the model to not see its previous tool calls and their
results, leading it to keep calling the same tool repeatedly.

The fix adds proper handling for:
- tool_use blocks: converted to OpenAI tool_calls format
- tool_result blocks: converted to role: "tool" messages

This ensures the model receives proper feedback about its tool calls
and can proceed with the task instead of looping.
This commit is contained in:
Roo Code 2025-12-16 17:46:32 +00:00
parent a7b192adca
commit ea070ba645
2 changed files with 570 additions and 76 deletions

View file

@ -179,4 +179,327 @@ describe("convertToR1Format", () => {
expect(convertToR1Format(input)).toEqual(expected)
})
// Tool handling tests
describe("tool_use handling", () => {
it("should convert tool_use blocks to tool_calls in assistant messages", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Read the file" },
{
role: "assistant",
content: [
{ type: "text", text: "I'll read the file for you." },
{
type: "tool_use",
id: "toolu_123",
name: "read_file",
input: { path: "test.txt" },
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ role: "user", content: "Read the file" })
const assistantMsg = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.role).toBe("assistant")
expect(assistantMsg.content).toBe("I'll read the file for you.")
expect(assistantMsg.tool_calls).toEqual([
{
id: "toolu_123",
type: "function",
function: {
name: "read_file",
arguments: JSON.stringify({ path: "test.txt" }),
},
},
])
})
it("should convert multiple tool_use blocks to multiple tool_calls", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_1",
name: "read_file",
input: { path: "file1.txt" },
},
{
type: "tool_use",
id: "toolu_2",
name: "read_file",
input: { path: "file2.txt" },
},
],
},
]
const result = convertToR1Format(input)
const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.tool_calls).toHaveLength(2)
expect(assistantMsg.tool_calls![0].id).toBe("toolu_1")
expect(assistantMsg.tool_calls![1].id).toBe("toolu_2")
})
it("should handle assistant message with only tool_use (no text)", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_123",
name: "update_todo_list",
input: { todos: "[ ] Task 1\n[ ] Task 2" },
},
],
},
]
const result = convertToR1Format(input)
const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.role).toBe("assistant")
expect(assistantMsg.content).toBeNull()
expect(assistantMsg.tool_calls).toHaveLength(1)
expect((assistantMsg.tool_calls![0] as any).function.name).toBe("update_todo_list")
})
})
describe("tool_result handling", () => {
it("should convert tool_result blocks to tool messages", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_123",
content: "File contents here",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
const toolMsg = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMsg.role).toBe("tool")
expect(toolMsg.tool_call_id).toBe("toolu_123")
expect(toolMsg.content).toBe("File contents here")
})
it("should convert multiple tool_result blocks to multiple tool messages", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_1",
content: "Result 1",
},
{
type: "tool_result",
tool_use_id: "toolu_2",
content: "Result 2",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(2)
expect((result[0] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_1")
expect((result[1] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_2")
})
it("should handle tool_result with array content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_123",
content: [
{ type: "text", text: "Line 1" },
{ type: "text", text: "Line 2" },
],
},
],
},
]
const result = convertToR1Format(input)
const toolMsg = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMsg.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: "toolu_123",
content: "Tool result",
},
{
type: "text",
text: "User feedback",
},
],
},
]
const result = convertToR1Format(input)
// Tool result should come first, then user message
expect(result).toHaveLength(2)
expect(result[0].role).toBe("tool")
expect((result[0] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_123")
expect(result[1].role).toBe("user")
expect((result[1] as OpenAI.Chat.ChatCompletionUserMessageParam).content).toBe("User feedback")
})
})
describe("complete tool call flow", () => {
it("should handle a complete tool call cycle (request -> tool_use -> tool_result -> response)", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Update my todo list" },
{
role: "assistant",
content: [
{ type: "text", text: "I'll update your todo list." },
{
type: "tool_use",
id: "toolu_update_123",
name: "update_todo_list",
input: { todos: "[ ] Task 1\n[x] Task 2" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_update_123",
content: "Todo list updated successfully.",
},
],
},
{ role: "assistant", content: "Your todo list has been updated!" },
]
const result = convertToR1Format(input)
expect(result).toHaveLength(4)
// First user message
expect(result[0]).toEqual({ role: "user", content: "Update my todo list" })
// Assistant with tool_calls
const assistantWithTool = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantWithTool.role).toBe("assistant")
expect(assistantWithTool.content).toBe("I'll update your todo list.")
expect(assistantWithTool.tool_calls).toHaveLength(1)
expect(assistantWithTool.tool_calls![0].id).toBe("toolu_update_123")
// Tool result
const toolResult = result[2] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolResult.role).toBe("tool")
expect(toolResult.tool_call_id).toBe("toolu_update_123")
expect(toolResult.content).toBe("Todo list updated successfully.")
// Final assistant response
expect(result[3]).toEqual({
role: "assistant",
content: "Your todo list has been updated!",
})
})
it("should not merge assistant messages when tool_calls are present", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{ role: "assistant", content: "Following up on that..." },
]
const result = convertToR1Format(input)
// Should have 2 separate messages, not merged
expect(result).toHaveLength(2)
expect((result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam).tool_calls).toBeDefined()
expect((result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam).tool_calls).toBeUndefined()
})
it("should handle multiple consecutive tool uses followed by results", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_1",
name: "read_file",
input: { path: "file1.txt" },
},
{
type: "tool_use",
id: "toolu_2",
name: "read_file",
input: { path: "file2.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_1",
content: "Content of file 1",
},
{
type: "tool_result",
tool_use_id: "toolu_2",
content: "Content of file 2",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(3) // 1 assistant + 2 tool messages
const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.tool_calls).toHaveLength(2)
expect((result[1] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_1")
expect((result[2] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_2")
})
})
})

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
@ -12,87 +13,257 @@ type AnthropicMessage = Anthropic.Messages.MessageParam
* Converts Anthropic messages to OpenAI format while merging consecutive messages with the same role.
* This is required for DeepSeek Reasoner which does not support successive messages with the same role.
*
* This function also handles tool_use and tool_result blocks:
* - tool_use blocks in assistant messages are converted to tool_calls
* - tool_result blocks in user messages are converted to role: "tool" messages
*
* @param messages Array of Anthropic messages
* @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
if (Array.isArray(message.content)) {
const textParts: string[] = []
const imageParts: ContentPartImage[] = []
message.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)
messageContent = parts
} else {
messageContent = textParts.join("\n")
}
} else {
messageContent = message.content
for (const message of messages) {
if (message.role === "user") {
processUserMessage(message, result)
} else if (message.role === "assistant") {
processAssistantMessage(message, result)
}
}
// 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
}
}
} 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)
}
}
return merged
}, [])
return result
}
/**
* Process a user message, handling tool_result blocks separately from text/image content
*/
function processUserMessage(message: AnthropicMessage, result: Message[]): void {
if (typeof message.content === "string") {
// Simple string content - merge with previous user message if possible
mergeOrAddUserMessage(message.content, result)
return
}
// Separate tool_result blocks from other content
const toolResults: Anthropic.ToolResultBlockParam[] = []
const otherContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
for (const block of message.content) {
if (block.type === "tool_result") {
toolResults.push(block)
} else if (block.type === "text" || block.type === "image") {
otherContent.push(block)
}
// Ignore other block types (user cannot send tool_use)
}
// Process tool_result blocks first - they become separate "tool" messages
// This must come before user content to maintain correct message order
for (const toolResult of toolResults) {
const content =
typeof toolResult.content === "string"
? toolResult.content
: (toolResult.content?.map((part) => (part.type === "text" ? part.text : "")).join("\n") ?? "")
const toolMessage: ToolMessage = {
role: "tool",
tool_call_id: toolResult.tool_use_id,
content: content,
}
result.push(toolMessage)
}
// Process remaining content (text and images)
if (otherContent.length > 0) {
const { content, hasImages } = convertUserContent(otherContent)
if (hasImages) {
// If there are images, try to merge with previous user message if possible
mergeOrAddUserMessageWithArrayContent(content as (ContentPartText | ContentPartImage)[], result)
} else {
// Text only - can merge with previous user message
const textContent =
typeof content === "string" ? content : (content as ContentPartText[]).map((p) => p.text).join("\n")
mergeOrAddUserMessage(textContent, result)
}
}
}
/**
* Process an assistant message, handling tool_use blocks as tool_calls
*/
function processAssistantMessage(message: AnthropicMessage, result: Message[]): void {
if (typeof message.content === "string") {
// Simple string content - merge with previous assistant message if possible
mergeOrAddAssistantMessage(message.content, undefined, result)
return
}
// Separate tool_use blocks from other content
const toolUses: Anthropic.ToolUseBlockParam[] = []
const otherContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
for (const block of message.content) {
if (block.type === "tool_use") {
toolUses.push(block)
} else if (block.type === "text" || block.type === "image") {
otherContent.push(block)
}
// Ignore other block types (assistant cannot send tool_result)
}
// Convert text content - preserve empty strings
let textContent: string | undefined
if (otherContent.length > 0) {
const texts = otherContent
.filter((part) => part.type === "text")
.map((part) => (part as Anthropic.TextBlockParam).text)
// If there were text blocks, join them (even if empty)
// If there were no text blocks, textContent remains undefined
if (texts.length > 0) {
textContent = texts.join("\n")
}
}
// Convert tool_use blocks to tool_calls
let toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined
if (toolUses.length > 0) {
toolCalls = toolUses.map((toolUse) => ({
id: toolUse.id,
type: "function" as const,
function: {
name: toolUse.name,
arguments: JSON.stringify(toolUse.input),
},
}))
}
// If there are tool calls, we cannot merge (tool_calls must be in their own message)
if (toolCalls && toolCalls.length > 0) {
const assistantMessage: AssistantMessage = {
role: "assistant",
content: textContent || null,
tool_calls: toolCalls,
}
result.push(assistantMessage)
} else if (textContent !== undefined) {
// No tool calls - can merge with previous assistant message
// Note: textContent can be empty string "" which should be preserved
mergeOrAddAssistantMessage(textContent, undefined, result)
}
}
/**
* Convert user content blocks to OpenAI format
*/
function convertUserContent(content: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]): {
content: string | (ContentPartText | ContentPartImage)[]
hasImages: boolean
} {
const textParts: string[] = []
const imageParts: ContentPartImage[] = []
let hasImages = false
for (const part of content) {
if (part.type === "text") {
textParts.push(part.text)
} else 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 { content: parts, hasImages: true }
}
return { content: textParts.join("\n"), hasImages: false }
}
/**
* Merge text content with the previous user message if possible, or add a new one
*/
function mergeOrAddUserMessage(content: string, result: Message[]): void {
const lastMessage = result[result.length - 1]
if (lastMessage?.role === "user") {
// Merge with previous user message
if (typeof lastMessage.content === "string") {
lastMessage.content = lastMessage.content + "\n" + content
} else if (Array.isArray(lastMessage.content)) {
// Previous message has array content - add text to it
lastMessage.content.push({ type: "text", text: content })
}
} else {
// Add new user message
result.push({ role: "user", content })
}
}
/**
* Merge array content (with images) with the previous user message if possible, or add a new one
*/
function mergeOrAddUserMessageWithArrayContent(
content: (ContentPartText | ContentPartImage)[],
result: Message[],
): void {
const lastMessage = result[result.length - 1]
if (lastMessage?.role === "user") {
// Merge with previous user message
if (typeof lastMessage.content === "string") {
// Convert string to array and append new content
lastMessage.content = [{ type: "text", text: lastMessage.content }, ...content]
} else if (Array.isArray(lastMessage.content)) {
// Previous message has array content - append new content
lastMessage.content.push(...content)
}
} else {
// Add new user message
result.push({ role: "user", content })
}
}
/**
* Merge text content with the previous assistant message if possible, or add a new one
*/
function mergeOrAddAssistantMessage(
content: string | undefined,
toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined,
result: Message[],
): void {
const lastMessage = result[result.length - 1]
const hasContent = content !== undefined
// Can only merge if there are no tool_calls and the previous message is an assistant without tool_calls
if (
lastMessage?.role === "assistant" &&
!toolCalls &&
!(lastMessage as AssistantMessage).tool_calls &&
hasContent &&
content // Only merge non-empty content
) {
// Merge with previous assistant message
if (typeof lastMessage.content === "string") {
lastMessage.content = lastMessage.content + "\n" + content
} else if (lastMessage.content === null || lastMessage.content === undefined) {
lastMessage.content = content
}
} else if (hasContent || toolCalls) {
// Add new assistant message (including empty string content)
const assistantMessage: AssistantMessage = {
role: "assistant",
content: content ?? null,
...(toolCalls && toolCalls.length > 0 && { tool_calls: toolCalls }),
}
result.push(assistantMessage)
}
}