Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 5468b76

Browse files
committed
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
1 parent ad25634 commit 5468b76

4 files changed

Lines changed: 277 additions & 3 deletions

File tree

src/core/prompts/responses.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,16 @@ export const formatResponse = {
3939
suggestion: "Try to continue without this file, or ask the user to update the .rooignore file",
4040
}),
4141

42-
noToolsUsed: () => {
42+
noToolsUsed: (malformedToolCallInfo?: string) => {
4343
const instructions = getToolInstructionsReminder()
4444

45+
const malformedHint = malformedToolCallInfo
46+
? `\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.`
47+
: ""
48+
4549
return `[ERROR] You did not use a tool in your previous response! Please retry with a tool use.
4650
47-
${instructions}
51+
${instructions}${malformedHint}
4852
4953
# Next Steps
5054

src/core/task/Task.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { getTaskDirectoryPath } from "../../utils/storage"
9595
import { formatResponse } from "../prompts/responses"
9696
import { SYSTEM_PROMPT } from "../prompts/system"
9797
import { buildNativeToolsArrayWithRestrictions } from "./build-tools"
98+
import { recoverMalformedToolCall, formatRecoveredToolCall } from "./malformed-tool-call-recovery"
9899

99100
// core modules
100101
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
@@ -3604,10 +3605,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
36043605
this.consecutiveMistakeCount++
36053606
}
36063607

3608+
// Attempt to detect malformed XML-style tool calls in the text output.
3609+
// This helps open-weight models self-correct by providing specific feedback.
3610+
let malformedToolCallInfo: string | undefined
3611+
if (assistantMessage) {
3612+
const recovered = recoverMalformedToolCall(assistantMessage)
3613+
if (recovered) {
3614+
malformedToolCallInfo = formatRecoveredToolCall(recovered)
3615+
}
3616+
}
3617+
36073618
// Use the task's locked protocol for consistent behavior
36083619
this.userMessageContent.push({
36093620
type: "text",
3610-
text: formatResponse.noToolsUsed(),
3621+
text: formatResponse.noToolsUsed(malformedToolCallInfo),
36113622
})
36123623
} else {
36133624
// Reset counter when tools are used successfully
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { recoverMalformedToolCall, formatRecoveredToolCall } from "../malformed-tool-call-recovery"
2+
3+
describe("recoverMalformedToolCall", () => {
4+
describe("Pattern 1: <function=TOOL_NAME><parameter=PARAM_NAME>VALUE</parameter></function>", () => {
5+
it("should recover a basic function-style tool call", () => {
6+
const text = `<function=attempt_completion>
7+
<parameter=result>
8+
Task completed successfully.
9+
</parameter>
10+
</function>`
11+
12+
const result = recoverMalformedToolCall(text)
13+
14+
expect(result).not.toBeNull()
15+
expect(result!.toolName).toBe("attempt_completion")
16+
expect(result!.parameters.result).toBe("Task completed successfully.")
17+
})
18+
19+
it("should recover a function-style tool call with trailing </tool_call>", () => {
20+
const text = `<function=attempt_completion>
21+
<parameter=result>
22+
LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT...
23+
</parameter>
24+
</function>
25+
</tool_call>`
26+
27+
const result = recoverMalformedToolCall(text)
28+
29+
expect(result).not.toBeNull()
30+
expect(result!.toolName).toBe("attempt_completion")
31+
expect(result!.parameters.result).toBe("LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT...")
32+
})
33+
34+
it("should recover a function-style tool call wrapped in <tool_call>", () => {
35+
const text = `<tool_call>
36+
<function=read_file>
37+
<parameter=path>/src/main.ts</parameter>
38+
</function>
39+
</tool_call>`
40+
41+
const result = recoverMalformedToolCall(text)
42+
43+
expect(result).not.toBeNull()
44+
expect(result!.toolName).toBe("read_file")
45+
expect(result!.parameters.path).toBe("/src/main.ts")
46+
})
47+
48+
it("should recover multiple parameters", () => {
49+
const text = `<function=write_to_file>
50+
<parameter=path>/src/test.ts</parameter>
51+
<parameter=content>console.log("hello")</parameter>
52+
</function>`
53+
54+
const result = recoverMalformedToolCall(text)
55+
56+
expect(result).not.toBeNull()
57+
expect(result!.toolName).toBe("write_to_file")
58+
expect(result!.parameters.path).toBe("/src/test.ts")
59+
expect(result!.parameters.content).toBe('console.log("hello")')
60+
})
61+
62+
it("should recover tool call with surrounding text/reasoning", () => {
63+
const text = `I will now complete the task.
64+
65+
<function=attempt_completion>
66+
<parameter=result>
67+
Done!
68+
</parameter>
69+
</function>
70+
71+
That should do it.`
72+
73+
const result = recoverMalformedToolCall(text)
74+
75+
expect(result).not.toBeNull()
76+
expect(result!.toolName).toBe("attempt_completion")
77+
expect(result!.parameters.result).toBe("Done!")
78+
})
79+
})
80+
81+
describe("Pattern 2: XML-style <tool_name><param>value</param></tool_name>", () => {
82+
it("should recover an XML-style tool call", () => {
83+
const text = `<read_file>
84+
<path>/src/main.ts</path>
85+
</read_file>`
86+
87+
const result = recoverMalformedToolCall(text)
88+
89+
expect(result).not.toBeNull()
90+
expect(result!.toolName).toBe("read_file")
91+
expect(result!.parameters.path).toBe("/src/main.ts")
92+
})
93+
94+
it("should recover XML-style tool call with multiple parameters", () => {
95+
const text = `<execute_command>
96+
<command>npm test</command>
97+
</execute_command>`
98+
99+
const result = recoverMalformedToolCall(text)
100+
101+
expect(result).not.toBeNull()
102+
expect(result!.toolName).toBe("execute_command")
103+
expect(result!.parameters.command).toBe("npm test")
104+
})
105+
})
106+
107+
describe("No match cases", () => {
108+
it("should return null for plain text without tool call patterns", () => {
109+
const text = "I need to think about this problem more carefully."
110+
const result = recoverMalformedToolCall(text)
111+
expect(result).toBeNull()
112+
})
113+
114+
it("should return null for empty string", () => {
115+
const result = recoverMalformedToolCall("")
116+
expect(result).toBeNull()
117+
})
118+
119+
it("should return null for random XML that does not look like a tool call", () => {
120+
const text = "<div><span>Hello</span></div>"
121+
const result = recoverMalformedToolCall(text)
122+
// This might match pattern 2, but div/span don't have underscore names
123+
// The regex requires [a-z_]+ which matches div, but the inner must also match
124+
expect(result).toBeNull()
125+
})
126+
})
127+
})
128+
129+
describe("formatRecoveredToolCall", () => {
130+
it("should format a simple recovered tool call", () => {
131+
const recovered = {
132+
toolName: "attempt_completion",
133+
parameters: { result: "Task done" },
134+
}
135+
136+
const formatted = formatRecoveredToolCall(recovered)
137+
138+
expect(formatted).toContain("Tool: attempt_completion")
139+
expect(formatted).toContain("result")
140+
expect(formatted).toContain("Task done")
141+
})
142+
143+
it("should truncate long parameter values", () => {
144+
const longValue = "x".repeat(200)
145+
const recovered = {
146+
toolName: "write_to_file",
147+
parameters: { content: longValue },
148+
}
149+
150+
const formatted = formatRecoveredToolCall(recovered)
151+
152+
expect(formatted).toContain("...")
153+
expect(formatted.length).toBeLessThan(longValue.length + 100)
154+
})
155+
156+
it("should format multiple parameters", () => {
157+
const recovered = {
158+
toolName: "write_to_file",
159+
parameters: { path: "/src/test.ts", content: "hello world" },
160+
}
161+
162+
const formatted = formatRecoveredToolCall(recovered)
163+
164+
expect(formatted).toContain("path")
165+
expect(formatted).toContain("content")
166+
expect(formatted).toContain("/src/test.ts")
167+
expect(formatted).toContain("hello world")
168+
})
169+
})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Malformed Tool Call Recovery
3+
*
4+
* Detects common XML-style tool call patterns in assistant text output when no
5+
* native tool calls were detected. This is especially common with open-weight
6+
* models (e.g., qwen3-coder) that sometimes emit tool calls as plain text
7+
* instead of using the native tool calling mechanism.
8+
*
9+
* IMPORTANT: Recovered information is NEVER auto-executed. It is only used to
10+
* provide a clearer retry message to the model so it can self-correct.
11+
*/
12+
13+
export interface RecoveredToolCall {
14+
toolName: string
15+
parameters: Record<string, string>
16+
}
17+
18+
/**
19+
* Attempts to recover a malformed tool call from the assistant's text output.
20+
*
21+
* Supported patterns:
22+
* 1. `<function=TOOL_NAME><parameter=PARAM_NAME>VALUE</parameter></function>` (with optional `</tool_call>`)
23+
* 2. `<tool_call><function=TOOL_NAME>...</function></tool_call>`
24+
* 3. XML-style `<TOOL_NAME><PARAM_NAME>VALUE</PARAM_NAME></TOOL_NAME>`
25+
*
26+
* @param text - The assistant's text output to scan
27+
* @returns A RecoveredToolCall if a malformed tool call is detected, or null otherwise
28+
*/
29+
export function recoverMalformedToolCall(text: string): RecoveredToolCall | null {
30+
// Pattern 1: <function=TOOL_NAME><parameter=PARAM_NAME>VALUE</parameter></function>
31+
// Optionally wrapped in <tool_call>...</tool_call>
32+
const functionPattern = /<function=([a-z_]+)>\s*([\s\S]*?)<\/function>/i
33+
const functionMatch = text.match(functionPattern)
34+
35+
if (functionMatch) {
36+
const toolName = functionMatch[1]
37+
const body = functionMatch[2]
38+
39+
const parameters: Record<string, string> = {}
40+
const paramPattern = /<parameter=([a-z_]+)>([\s\S]*?)<\/parameter>/gi
41+
let paramMatch
42+
43+
while ((paramMatch = paramPattern.exec(body)) !== null) {
44+
parameters[paramMatch[1]] = paramMatch[2].trim()
45+
}
46+
47+
return { toolName, parameters }
48+
}
49+
50+
// Pattern 2: XML-style <tool_name><param_name>value</param_name></tool_name>
51+
// Common with some models that try to emulate XML tool calling.
52+
// Requires at least one underscore in the tool name to avoid matching regular HTML tags.
53+
const xmlToolPattern = /<([a-z]+_[a-z_]+)>\s*((?:<[a-z_]+>[\s\S]*?<\/[a-z_]+>\s*)+)<\/\1>/i
54+
const xmlMatch = text.match(xmlToolPattern)
55+
56+
if (xmlMatch) {
57+
const toolName = xmlMatch[1]
58+
const body = xmlMatch[2]
59+
60+
const parameters: Record<string, string> = {}
61+
const paramPattern = /<([a-z_]+)>([\s\S]*?)<\/\1>/gi
62+
let paramMatch
63+
64+
while ((paramMatch = paramPattern.exec(body)) !== null) {
65+
parameters[paramMatch[1]] = paramMatch[2].trim()
66+
}
67+
68+
// Only return if we found at least one parameter
69+
if (Object.keys(parameters).length > 0) {
70+
return { toolName, parameters }
71+
}
72+
}
73+
74+
return null
75+
}
76+
77+
/**
78+
* Formats a recovered tool call into a human-readable summary for the retry message.
79+
*/
80+
export function formatRecoveredToolCall(recovered: RecoveredToolCall): string {
81+
const paramSummary = Object.entries(recovered.parameters)
82+
.map(([key, value]) => {
83+
// Truncate long parameter values to keep the message concise
84+
const truncated = value.length > 100 ? value.substring(0, 100) + "..." : value
85+
return ` - ${key}: "${truncated}"`
86+
})
87+
.join("\n")
88+
89+
return `Tool: ${recovered.toolName}\nParameters:\n${paramSummary}`
90+
}

0 commit comments

Comments
 (0)