Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/core/prompts/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions src/core/task/__tests__/malformed-tool-call-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
90 changes: 90 additions & 0 deletions src/core/task/malformed-tool-call-recovery.ts
Original file line number Diff line number Diff line change
@@ -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

Check failure

Code scanning / CodeQL

Inefficient regular expression High

This part of the regular expression may cause exponential backtracking on strings starting with '<a__><>' and containing many repetitions of '</><_>'.
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}`
}
Loading