From 5468b76bbbd8505268b6e7e5c02cc120bca1d8da Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 29 Apr 2026 23:52:16 +0000 Subject: [PATCH] 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 --- src/core/prompts/responses.ts | 8 +- src/core/task/Task.ts | 13 +- .../malformed-tool-call-recovery.spec.ts | 169 ++++++++++++++++++ src/core/task/malformed-tool-call-recovery.ts | 90 ++++++++++ 4 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 src/core/task/__tests__/malformed-tool-call-recovery.spec.ts create mode 100644 src/core/task/malformed-tool-call-recovery.ts diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 60b5b4123ac..526ee9c249f 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -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 diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 005bb0f292b..ae149120560 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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 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 diff --git a/src/core/task/__tests__/malformed-tool-call-recovery.spec.ts b/src/core/task/__tests__/malformed-tool-call-recovery.spec.ts new file mode 100644 index 00000000000..51afadc6781 --- /dev/null +++ b/src/core/task/__tests__/malformed-tool-call-recovery.spec.ts @@ -0,0 +1,169 @@ +import { recoverMalformedToolCall, formatRecoveredToolCall } from "../malformed-tool-call-recovery" + +describe("recoverMalformedToolCall", () => { + describe("Pattern 1: VALUE", () => { + it("should recover a basic function-style tool call", () => { + const text = ` + +Task completed successfully. + +` + + 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 ", () => { + const text = ` + +LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT... + + +` + + 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 ", () => { + const text = ` + +/src/main.ts + +` + + 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 = ` +/src/test.ts +console.log("hello") +` + + 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. + + + +Done! + + + +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 value", () => { + it("should recover an XML-style tool call", () => { + const text = ` +/src/main.ts +` + + 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 = ` +npm test +` + + 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 = "
Hello
" + 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") + }) +}) diff --git a/src/core/task/malformed-tool-call-recovery.ts b/src/core/task/malformed-tool-call-recovery.ts new file mode 100644 index 00000000000..b32d6d1f0d3 --- /dev/null +++ b/src/core/task/malformed-tool-call-recovery.ts @@ -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 +} + +/** + * Attempts to recover a malformed tool call from the assistant's text output. + * + * Supported patterns: + * 1. `VALUE` (with optional `
`) + * 2. `...` + * 3. XML-style `VALUE` + * + * @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: VALUE + // Optionally wrapped in ... + const functionPattern = /\s*([\s\S]*?)<\/function>/i + const functionMatch = text.match(functionPattern) + + if (functionMatch) { + const toolName = functionMatch[1] + const body = functionMatch[2] + + const parameters: Record = {} + const paramPattern = /([\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 value + // 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 = {} + 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}` +}