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}` +}