feat: add malformed tool call recovery for open-weight models

When models (especially open-weight models via OpenAI-compatible APIs)
emit tool calls as XML markup in their text output instead of using
native tool calling, Roo now detects these patterns and provides
specific feedback in the retry message.

This is non-invasive: it only activates when no native tool calls
were detected, and never auto-executes recovered tool calls. The
recovered information is used solely to give the model a clearer
retry message so it can self-correct.

Closes #12185
This commit is contained in:
Roo Code 2026-04-29 23:52:16 +00:00
parent ad25634905
commit 5468b76bbb
4 changed files with 277 additions and 3 deletions

View file

@ -39,12 +39,16 @@ export const formatResponse = {
suggestion: "Try to continue without this file, or ask the user to update the .rooignore file",
}),
noToolsUsed: () => {
noToolsUsed: (malformedToolCallInfo?: string) => {
const instructions = getToolInstructionsReminder()
const malformedHint = malformedToolCallInfo
? `\n\n# Malformed Tool Call Detected\n\nIt looks like you tried to call a tool using XML markup in your text response, but this is not supported. You must use the native/platform tool calling mechanism instead of writing XML tags.\n\nHere is what was detected in your response:\n${malformedToolCallInfo}\n\nPlease retry using the proper native tool calling mechanism with the correct tool name and parameters.`
: ""
return `[ERROR] You did not use a tool in your previous response! Please retry with a tool use.
${instructions}
${instructions}${malformedHint}
# Next Steps

View file

@ -95,6 +95,7 @@ import { getTaskDirectoryPath } from "../../utils/storage"
import { formatResponse } from "../prompts/responses"
import { SYSTEM_PROMPT } from "../prompts/system"
import { buildNativeToolsArrayWithRestrictions } from "./build-tools"
import { recoverMalformedToolCall, formatRecoveredToolCall } from "./malformed-tool-call-recovery"
// core modules
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
@ -3604,10 +3605,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.consecutiveMistakeCount++
}
// Attempt to detect malformed XML-style tool calls in the text output.
// This helps open-weight models self-correct by providing specific feedback.
let malformedToolCallInfo: string | undefined
if (assistantMessage) {
const recovered = recoverMalformedToolCall(assistantMessage)
if (recovered) {
malformedToolCallInfo = formatRecoveredToolCall(recovered)
}
}
// Use the task's locked protocol for consistent behavior
this.userMessageContent.push({
type: "text",
text: formatResponse.noToolsUsed(),
text: formatResponse.noToolsUsed(malformedToolCallInfo),
})
} else {
// Reset counter when tools are used successfully

View file

@ -0,0 +1,169 @@
import { recoverMalformedToolCall, formatRecoveredToolCall } from "../malformed-tool-call-recovery"
describe("recoverMalformedToolCall", () => {
describe("Pattern 1: <function=TOOL_NAME><parameter=PARAM_NAME>VALUE</parameter></function>", () => {
it("should recover a basic function-style tool call", () => {
const text = `<function=attempt_completion>
<parameter=result>
Task completed successfully.
</parameter>
</function>`
const result = recoverMalformedToolCall(text)
expect(result).not.toBeNull()
expect(result!.toolName).toBe("attempt_completion")
expect(result!.parameters.result).toBe("Task completed successfully.")
})
it("should recover a function-style tool call with trailing </tool_call>", () => {
const text = `<function=attempt_completion>
<parameter=result>
LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT...
</parameter>
</function>
</tool_call>`
const result = recoverMalformedToolCall(text)
expect(result).not.toBeNull()
expect(result!.toolName).toBe("attempt_completion")
expect(result!.parameters.result).toBe("LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT...")
})
it("should recover a function-style tool call wrapped in <tool_call>", () => {
const text = `<tool_call>
<function=read_file>
<parameter=path>/src/main.ts</parameter>
</function>
</tool_call>`
const result = recoverMalformedToolCall(text)
expect(result).not.toBeNull()
expect(result!.toolName).toBe("read_file")
expect(result!.parameters.path).toBe("/src/main.ts")
})
it("should recover multiple parameters", () => {
const text = `<function=write_to_file>
<parameter=path>/src/test.ts</parameter>
<parameter=content>console.log("hello")</parameter>
</function>`
const result = recoverMalformedToolCall(text)
expect(result).not.toBeNull()
expect(result!.toolName).toBe("write_to_file")
expect(result!.parameters.path).toBe("/src/test.ts")
expect(result!.parameters.content).toBe('console.log("hello")')
})
it("should recover tool call with surrounding text/reasoning", () => {
const text = `I will now complete the task.
<function=attempt_completion>
<parameter=result>
Done!
</parameter>
</function>
That should do it.`
const result = recoverMalformedToolCall(text)
expect(result).not.toBeNull()
expect(result!.toolName).toBe("attempt_completion")
expect(result!.parameters.result).toBe("Done!")
})
})
describe("Pattern 2: XML-style <tool_name><param>value</param></tool_name>", () => {
it("should recover an XML-style tool call", () => {
const text = `<read_file>
<path>/src/main.ts</path>
</read_file>`
const result = recoverMalformedToolCall(text)
expect(result).not.toBeNull()
expect(result!.toolName).toBe("read_file")
expect(result!.parameters.path).toBe("/src/main.ts")
})
it("should recover XML-style tool call with multiple parameters", () => {
const text = `<execute_command>
<command>npm test</command>
</execute_command>`
const result = recoverMalformedToolCall(text)
expect(result).not.toBeNull()
expect(result!.toolName).toBe("execute_command")
expect(result!.parameters.command).toBe("npm test")
})
})
describe("No match cases", () => {
it("should return null for plain text without tool call patterns", () => {
const text = "I need to think about this problem more carefully."
const result = recoverMalformedToolCall(text)
expect(result).toBeNull()
})
it("should return null for empty string", () => {
const result = recoverMalformedToolCall("")
expect(result).toBeNull()
})
it("should return null for random XML that does not look like a tool call", () => {
const text = "<div><span>Hello</span></div>"
const result = recoverMalformedToolCall(text)
// This might match pattern 2, but div/span don't have underscore names
// The regex requires [a-z_]+ which matches div, but the inner must also match
expect(result).toBeNull()
})
})
})
describe("formatRecoveredToolCall", () => {
it("should format a simple recovered tool call", () => {
const recovered = {
toolName: "attempt_completion",
parameters: { result: "Task done" },
}
const formatted = formatRecoveredToolCall(recovered)
expect(formatted).toContain("Tool: attempt_completion")
expect(formatted).toContain("result")
expect(formatted).toContain("Task done")
})
it("should truncate long parameter values", () => {
const longValue = "x".repeat(200)
const recovered = {
toolName: "write_to_file",
parameters: { content: longValue },
}
const formatted = formatRecoveredToolCall(recovered)
expect(formatted).toContain("...")
expect(formatted.length).toBeLessThan(longValue.length + 100)
})
it("should format multiple parameters", () => {
const recovered = {
toolName: "write_to_file",
parameters: { path: "/src/test.ts", content: "hello world" },
}
const formatted = formatRecoveredToolCall(recovered)
expect(formatted).toContain("path")
expect(formatted).toContain("content")
expect(formatted).toContain("/src/test.ts")
expect(formatted).toContain("hello world")
})
})

View file

@ -0,0 +1,90 @@
/**
* Malformed Tool Call Recovery
*
* Detects common XML-style tool call patterns in assistant text output when no
* native tool calls were detected. This is especially common with open-weight
* models (e.g., qwen3-coder) that sometimes emit tool calls as plain text
* instead of using the native tool calling mechanism.
*
* IMPORTANT: Recovered information is NEVER auto-executed. It is only used to
* provide a clearer retry message to the model so it can self-correct.
*/
export interface RecoveredToolCall {
toolName: string
parameters: Record<string, string>
}
/**
* Attempts to recover a malformed tool call from the assistant's text output.
*
* Supported patterns:
* 1. `<function=TOOL_NAME><parameter=PARAM_NAME>VALUE</parameter></function>` (with optional `</tool_call>`)
* 2. `<tool_call><function=TOOL_NAME>...</function></tool_call>`
* 3. XML-style `<TOOL_NAME><PARAM_NAME>VALUE</PARAM_NAME></TOOL_NAME>`
*
* @param text - The assistant's text output to scan
* @returns A RecoveredToolCall if a malformed tool call is detected, or null otherwise
*/
export function recoverMalformedToolCall(text: string): RecoveredToolCall | null {
// Pattern 1: <function=TOOL_NAME><parameter=PARAM_NAME>VALUE</parameter></function>
// Optionally wrapped in <tool_call>...</tool_call>
const functionPattern = /<function=([a-z_]+)>\s*([\s\S]*?)<\/function>/i
const functionMatch = text.match(functionPattern)
if (functionMatch) {
const toolName = functionMatch[1]
const body = functionMatch[2]
const parameters: Record<string, string> = {}
const paramPattern = /<parameter=([a-z_]+)>([\s\S]*?)<\/parameter>/gi
let paramMatch
while ((paramMatch = paramPattern.exec(body)) !== null) {
parameters[paramMatch[1]] = paramMatch[2].trim()
}
return { toolName, parameters }
}
// Pattern 2: XML-style <tool_name><param_name>value</param_name></tool_name>
// Common with some models that try to emulate XML tool calling.
// Requires at least one underscore in the tool name to avoid matching regular HTML tags.
const xmlToolPattern = /<([a-z]+_[a-z_]+)>\s*((?:<[a-z_]+>[\s\S]*?<\/[a-z_]+>\s*)+)<\/\1>/i
const xmlMatch = text.match(xmlToolPattern)
if (xmlMatch) {
const toolName = xmlMatch[1]
const body = xmlMatch[2]
const parameters: Record<string, string> = {}
const paramPattern = /<([a-z_]+)>([\s\S]*?)<\/\1>/gi
let paramMatch
while ((paramMatch = paramPattern.exec(body)) !== null) {
parameters[paramMatch[1]] = paramMatch[2].trim()
}
// Only return if we found at least one parameter
if (Object.keys(parameters).length > 0) {
return { toolName, parameters }
}
}
return null
}
/**
* Formats a recovered tool call into a human-readable summary for the retry message.
*/
export function formatRecoveredToolCall(recovered: RecoveredToolCall): string {
const paramSummary = Object.entries(recovered.parameters)
.map(([key, value]) => {
// Truncate long parameter values to keep the message concise
const truncated = value.length > 100 ? value.substring(0, 100) + "..." : value
return ` - ${key}: "${truncated}"`
})
.join("\n")
return `Tool: ${recovered.toolName}\nParameters:\n${paramSummary}`
}