mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
596783d365
commit
50689c4d61
2 changed files with 449 additions and 67 deletions
|
|
@ -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" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue