fix: handle tool messages in R1 format converter for DeepSeek Reasoner

The convertToR1Format function was ignoring tool_use and tool_result
message blocks, causing tool call history to be lost. This resulted
in infinite loops as the model kept retrying the same tool calls.

Changes:
- Add handling for tool_use blocks in assistant messages (convert to
  OpenAI tool_calls format)
- Add handling for tool_result blocks in user messages (convert to
  OpenAI tool role messages)
- Prevent merging of assistant messages that contain tool_calls
- Add comprehensive tests for tool message handling

Fixes #10063
This commit is contained in:
Roo Code 2025-12-13 11:28:36 +00:00
parent a3b258ad62
commit 0ff357ad4c
2 changed files with 492 additions and 71 deletions

View file

@ -179,4 +179,301 @@ describe("convertToR1Format", () => {
expect(convertToR1Format(input)).toEqual(expected)
})
describe("tool message handling", () => {
it("should convert tool_use blocks to OpenAI tool_calls format", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Read the file test.txt" },
{
role: "assistant",
content: [
{ type: "text", text: "I'll read that file for you." },
{
type: "tool_use",
id: "tool-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 test.txt" })
const assistantMsg = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.role).toBe("assistant")
expect(assistantMsg.content).toBe("I'll read that file for you.")
expect(assistantMsg.tool_calls).toHaveLength(1)
expect(assistantMsg.tool_calls![0]).toEqual({
id: "tool-123",
type: "function",
function: {
name: "read_file",
arguments: '{"path":"test.txt"}',
},
})
})
it("should convert tool_result blocks to OpenAI tool messages", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-123",
content: "File contents: Hello World",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "tool-123",
content: "File contents: 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-456",
content: [
{ type: "text", text: "Line 1" },
{ type: "text", text: "Line 2" },
],
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "tool-456",
content: "Line 1\nLine 2",
})
})
it("should handle multiple tool_use blocks in one 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 assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.tool_calls).toHaveLength(2)
expect(assistantMsg.tool_calls![0].id).toBe("tool-1")
expect(assistantMsg.tool_calls![1].id).toBe("tool-2")
})
it("should handle multiple tool_result blocks in one message", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "Contents of file1",
},
{
type: "tool_result",
tool_use_id: "tool-2",
content: "Contents of file2",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "tool-1",
content: "Contents of file1",
})
expect(result[1]).toEqual({
role: "tool",
tool_call_id: "tool-2",
content: "Contents of file2",
})
})
it("should handle full conversation with tool calls and results", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "What's in test.txt?" },
{
role: "assistant",
content: [
{ type: "text", text: "Let me check that file." },
{
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 test.txt contains: Hello, World!",
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(4)
// User message
expect(result[0]).toEqual({ role: "user", content: "What's in test.txt?" })
// Assistant with tool call
const assistantMsg = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.role).toBe("assistant")
expect(assistantMsg.content).toBe("Let me check that file.")
expect(assistantMsg.tool_calls).toHaveLength(1)
expect(assistantMsg.tool_calls![0].id).toBe("tool-abc")
// Tool result
expect(result[2]).toEqual({
role: "tool",
tool_call_id: "tool-abc",
content: "Hello, World!",
})
// Final assistant response
expect(result[3]).toEqual({
role: "assistant",
content: "The file test.txt contains: Hello, World!",
})
})
it("should handle tool_result mixed with text content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-123",
content: "Tool output here",
},
{ type: "text", text: "Also, here is some additional context." },
],
},
]
const result = convertToR1Format(input)
// Tool results should come first, then user text
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "tool-123",
content: "Tool output here",
})
expect(result[1]).toEqual({
role: "user",
content: "Also, here is some additional context.",
})
})
it("should not merge assistant messages after tool_calls", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "test_tool",
input: {},
},
],
},
{
role: "assistant",
content: "Follow-up text",
},
]
const result = convertToR1Format(input)
// Should NOT merge because first message has tool_calls
expect(result).toHaveLength(2)
expect((result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam).tool_calls).toBeDefined()
expect(result[1]).toEqual({
role: "assistant",
content: "Follow-up text",
})
})
it("should handle tool_use without text content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-only",
name: "some_tool",
input: { key: "value" },
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMsg.role).toBe("assistant")
expect(assistantMsg.content).toBeNull()
expect(assistantMsg.tool_calls).toHaveLength(1)
})
})
})

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,210 @@ 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.
*
* 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 tool role 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[] = []
for (const message of messages) {
if (typeof message.content === "string") {
// Simple string content - can be merged with previous same-role message
appendOrMergeMessage(result, message.role, message.content)
} else {
// Array content - need to process each block
if (message.role === "user") {
processUserMessage(result, message.content)
} else if (message.role === "assistant") {
processAssistantMessage(result, message.content)
}
}
}
message.content.forEach((part) => {
return result
}
/**
* Process user message content blocks, handling tool_result blocks separately
*/
function processUserMessage(result: Message[], content: Anthropic.Messages.ContentBlockParam[]): void {
const textParts: string[] = []
const imageParts: ContentPartImage[] = []
const toolResults: Anthropic.ToolResultBlockParam[] = []
let hasImages = false
// Separate tool results from other content
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}` },
})
} else if (part.type === "tool_result") {
toolResults.push(part)
}
}
// First, add tool result messages (they must come right after assistant tool_calls)
for (const toolResult of toolResults) {
const toolContent = extractToolResultContent(toolResult)
const toolMessage: ToolMessage = {
role: "tool",
tool_call_id: toolResult.tool_use_id,
content: toolContent,
}
result.push(toolMessage)
}
// Then add non-tool content as user message
if (textParts.length > 0 || imageParts.length > 0) {
let messageContent: string | (ContentPartText | ContentPartImage)[]
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")
}
appendOrMergeMessage(result, "user", messageContent)
}
}
/**
* Process assistant message content blocks, handling tool_use blocks
*/
function processAssistantMessage(result: Message[], content: Anthropic.Messages.ContentBlockParam[]): void {
const textParts: string[] = []
const toolUses: Anthropic.ToolUseBlockParam[] = []
// Separate tool uses from text content
for (const part of content) {
if (part.type === "text") {
textParts.push(part.text)
} else if (part.type === "tool_use") {
toolUses.push(part)
}
// Images from assistant are ignored (not possible in practice)
}
const textContent = textParts.length > 0 ? textParts.join("\n") : undefined
if (toolUses.length > 0) {
// If there are tool uses, create a new assistant message with tool_calls
// Tool calls cannot be merged with previous messages
const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolUses.map((toolUse) => ({
id: toolUse.id,
type: "function" as const,
function: {
name: toolUse.name,
arguments: JSON.stringify(toolUse.input),
},
}))
const assistantMessage: AssistantMessage = {
role: "assistant",
content: textContent ?? null,
tool_calls: toolCalls,
}
result.push(assistantMessage)
} else if (textContent) {
// No tool uses - can merge with previous assistant message
appendOrMergeMessage(result, "assistant", textContent)
}
}
/**
* Extract content string from a tool result block
*/
function extractToolResultContent(toolResult: Anthropic.ToolResultBlockParam): string {
if (typeof toolResult.content === "string") {
return toolResult.content
}
if (Array.isArray(toolResult.content)) {
return toolResult.content
.map((part) => {
if (part.type === "text") {
textParts.push(part.text)
return 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}` },
})
return "(image content)"
}
return ""
})
.filter(Boolean)
.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
}
// 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 ""
}
/**
* Append a message to the result array, merging with the previous message if it has the same role
* and neither is a tool message
*/
function appendOrMergeMessage(
result: Message[],
role: "user" | "assistant",
content: string | (ContentPartText | ContentPartImage)[],
): void {
const lastMessage = result[result.length - 1]
// Can only merge if:
// 1. Last message exists and has the same role
// 2. Last message is not a tool message
// 3. Last message doesn't have tool_calls (for assistant messages)
if (
lastMessage &&
lastMessage.role === role &&
!("tool_call_id" in lastMessage) &&
!("tool_calls" in lastMessage && lastMessage.tool_calls)
) {
// Merge content
if (typeof lastMessage.content === "string" && typeof content === "string") {
lastMessage.content += `\n${content}`
} else {
// Convert both to array format and merge
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)
}
}
}