mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
a3b258ad62
commit
0ff357ad4c
2 changed files with 492 additions and 71 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue