feat: add XML tool call fallback parser for non-native tool calling models

When a model does not support native function/tool calling (common with
some OpenAI-compatible proxies), it outputs tool calls as XML text
instead of structured tool_call events. The extension previously rejected
these with a "Model Response Incomplete" error.

This commit adds a fallback parser that detects XML-formatted tool calls
in text responses (e.g. <read_file><path>...</path></read_file>) and
converts them to ToolUse blocks, allowing the tools to execute normally.

The fallback only activates when no native tool_use blocks are found in
the assistant message, ensuring zero impact on providers that already
support native function calling.

Closes #11187
This commit is contained in:
Roo Code 2026-02-07 09:53:11 +00:00
parent 7fc42d70ea
commit d12f7877e8
3 changed files with 479 additions and 1 deletions

View file

@ -0,0 +1,138 @@
/**
* Fallback parser for XML-formatted tool calls in text responses.
*
* When a model doesn't support native function/tool calling (common with
* some OpenAI-compatible proxies), it may output tool calls as XML text
* instead of structured tool_call events. This parser extracts those
* XML tool calls from the text and converts them to ToolUse blocks.
*
* Supported format:
* <tool_name>
* <param_name>value</param_name>
* </tool_name>
*
* For example:
* <read_file>
* <path>src/main.ts</path>
* </read_file>
*/
import { type ToolName, toolNames } from "@roo-code/types"
import { type ToolUse, type ToolParamName, toolParamNames, TOOL_ALIASES } from "../../shared/tools"
/**
* Set of all recognized tool names (canonical names + aliases).
*/
const ALL_TOOL_NAMES: Set<string> = new Set([...toolNames, ...Object.keys(TOOL_ALIASES)])
/**
* Set of all recognized parameter names for quick lookup.
*/
const VALID_PARAM_NAMES: Set<string> = new Set(toolParamNames)
/**
* Resolve an alias to its canonical tool name, or return the name as-is if not aliased.
*/
function resolveAlias(name: string): ToolName | undefined {
if ((toolNames as readonly string[]).includes(name)) {
return name as ToolName
}
const aliased = TOOL_ALIASES[name]
return aliased ?? undefined
}
/**
* Extract parameter values from the inner XML content of a tool call.
*
* Handles both single-line and multi-line parameter values:
* <path>src/main.ts</path>
* <content>line 1\nline 2</content>
*/
function extractParams(innerXml: string): Partial<Record<ToolParamName, string>> {
const params: Partial<Record<ToolParamName, string>> = {}
// Match XML parameter tags - supports multi-line content within params.
// Uses a non-greedy match to handle multiple params correctly.
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/g
let match: RegExpExecArray | null
while ((match = paramRegex.exec(innerXml)) !== null) {
const paramName = match[1]
const paramValue = match[2]
if (VALID_PARAM_NAMES.has(paramName)) {
params[paramName as ToolParamName] = paramValue
}
}
return params
}
export interface XmlToolCallParseResult {
/** The tool uses parsed from the text. */
toolUses: ToolUse[]
/** Whether any tool calls were found and parsed. */
found: boolean
}
/**
* Parse XML-formatted tool calls from text content.
*
* Scans the given text for patterns like `<tool_name><param>value</param></tool_name>`
* where tool_name is a recognized tool name. Returns an array of ToolUse blocks.
*
* @param text - The text content to scan for XML tool calls
* @returns Parse result with found tool uses
*/
export function parseXmlToolCalls(text: string): XmlToolCallParseResult {
const toolUses: ToolUse[] = []
if (!text || text.trim().length === 0) {
return { toolUses, found: false }
}
// Build a regex that matches any known tool name as an XML tag.
// The tool name must be a complete word boundary to avoid false positives
// with tags like <environment_details>.
const toolNamePattern = [...ALL_TOOL_NAMES].join("|")
// Match: <tool_name>...content...</tool_name>
// - Tool name must be an exact match (not a substring of another tag)
// - Content between tags is captured (can be multi-line)
const toolCallRegex = new RegExp(`<(${toolNamePattern})>([\\s\\S]*?)<\\/\\1>`, "g")
let match: RegExpExecArray | null
let idCounter = 0
while ((match = toolCallRegex.exec(text)) !== null) {
const rawToolName = match[1]
const innerContent = match[2]
const canonicalName = resolveAlias(rawToolName)
if (!canonicalName) {
continue
}
const params = extractParams(innerContent)
idCounter++
const toolUse: ToolUse = {
type: "tool_use",
id: `xml_fallback_${idCounter}_${Date.now()}`,
name: canonicalName,
params,
partial: false,
usedLegacyFormat: true,
}
// If the alias differs from canonical, preserve it
if (rawToolName !== canonicalName) {
toolUse.originalName = rawToolName
}
toolUses.push(toolUse)
}
return { toolUses, found: toolUses.length > 0 }
}

View file

@ -0,0 +1,304 @@
import { parseXmlToolCalls } from "../XmlToolCallFallbackParser"
describe("XmlToolCallFallbackParser", () => {
describe("parseXmlToolCalls", () => {
it("should return empty result for empty text", () => {
expect(parseXmlToolCalls("").found).toBe(false)
expect(parseXmlToolCalls("").toolUses).toEqual([])
})
it("should return empty result for text with no tool calls", () => {
const text = "I'll help you with that task. Let me think about the best approach."
const result = parseXmlToolCalls(text)
expect(result.found).toBe(false)
expect(result.toolUses).toEqual([])
})
it("should parse a single read_file tool call", () => {
const text = `Let me read the file first.
<read_file>
<path>src/main.ts</path>
</read_file>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].type).toBe("tool_use")
expect(result.toolUses[0].name).toBe("read_file")
expect(result.toolUses[0].params.path).toBe("src/main.ts")
expect(result.toolUses[0].partial).toBe(false)
expect(result.toolUses[0].usedLegacyFormat).toBe(true)
})
it("should parse a write_to_file tool call with multi-line content", () => {
const text = `<write_to_file>
<path>src/hello.ts</path>
<content>export function hello() {
console.log("hello world")
}
</content>
</write_to_file>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("write_to_file")
expect(result.toolUses[0].params.path).toBe("src/hello.ts")
expect(result.toolUses[0].params.content).toContain("hello world")
})
it("should parse multiple tool calls in a single response", () => {
const text = `I'll read both files.
<read_file>
<path>src/a.ts</path>
</read_file>
Now let me also check the other file.
<read_file>
<path>src/b.ts</path>
</read_file>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(2)
expect(result.toolUses[0].params.path).toBe("src/a.ts")
expect(result.toolUses[1].params.path).toBe("src/b.ts")
})
it("should parse execute_command tool call", () => {
const text = `<execute_command>
<command>npm test</command>
</execute_command>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("execute_command")
expect(result.toolUses[0].params.command).toBe("npm test")
})
it("should parse search_files tool call with multiple params", () => {
const text = `<search_files>
<path>src</path>
<regex>function\\s+\\w+</regex>
<file_pattern>*.ts</file_pattern>
</search_files>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("search_files")
expect(result.toolUses[0].params.path).toBe("src")
expect(result.toolUses[0].params.regex).toBe("function\\s+\\w+")
expect(result.toolUses[0].params.file_pattern).toBe("*.ts")
})
it("should parse list_files tool call", () => {
const text = `<list_files>
<path>src</path>
<recursive>true</recursive>
</list_files>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("list_files")
expect(result.toolUses[0].params.path).toBe("src")
expect(result.toolUses[0].params.recursive).toBe("true")
})
it("should parse attempt_completion tool call", () => {
const text = `<attempt_completion>
<result>I've completed the task successfully.</result>
</attempt_completion>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("attempt_completion")
expect(result.toolUses[0].params.result).toBe("I've completed the task successfully.")
})
it("should parse apply_diff tool call with multi-line diff content", () => {
const text = `<apply_diff>
<path>src/main.ts</path>
<diff><<<<<<< SEARCH
:start_line:1
-------
old content
=======
new content
>>>>>>> REPLACE</diff>
</apply_diff>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("apply_diff")
expect(result.toolUses[0].params.path).toBe("src/main.ts")
expect(result.toolUses[0].params.diff).toContain("SEARCH")
expect(result.toolUses[0].params.diff).toContain("REPLACE")
})
it("should handle tool aliases (write_file -> write_to_file)", () => {
const text = `<write_file>
<path>src/test.ts</path>
<content>test content</content>
</write_file>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("write_to_file")
expect(result.toolUses[0].originalName).toBe("write_file")
})
it("should not match non-tool XML tags", () => {
const text = `<environment_details>
VS Code version: 1.107.1
</environment_details>
<thinking>
Let me analyze the situation.
</thinking>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(false)
expect(result.toolUses).toEqual([])
})
it("should handle tool calls inline with surrounding text", () => {
const text = `First, I'll check the directory. <list_files><path>.</path><recursive>false</recursive></list_files> That should give us what we need.`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("list_files")
expect(result.toolUses[0].params.path).toBe(".")
})
it("should generate unique IDs for each tool call", () => {
const text = `<read_file><path>a.ts</path></read_file>
<read_file><path>b.ts</path></read_file>`
const result = parseXmlToolCalls(text)
expect(result.toolUses).toHaveLength(2)
expect(result.toolUses[0].id).not.toBe(result.toolUses[1].id)
})
it("should parse ask_followup_question tool call", () => {
const text = `<ask_followup_question>
<question>Which file would you like me to modify?</question>
</ask_followup_question>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("ask_followup_question")
expect(result.toolUses[0].params.question).toBe("Which file would you like me to modify?")
})
it("should parse switch_mode tool call", () => {
const text = `<switch_mode>
<mode_slug>architect</mode_slug>
<reason>Need to design the solution first</reason>
</switch_mode>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("switch_mode")
expect(result.toolUses[0].params.mode_slug).toBe("architect")
expect(result.toolUses[0].params.reason).toBe("Need to design the solution first")
})
it("should parse new_task tool call", () => {
const text = `<new_task>
<mode>code</mode>
<message>Implement the feature</message>
</new_task>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("new_task")
expect(result.toolUses[0].params.mode).toBe("code")
expect(result.toolUses[0].params.message).toBe("Implement the feature")
})
it("should parse use_mcp_tool tool call", () => {
const text = `<use_mcp_tool>
<server_name>my-server</server_name>
<tool_name>my-tool</tool_name>
<arguments>{"key": "value"}</arguments>
</use_mcp_tool>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("use_mcp_tool")
expect(result.toolUses[0].params.server_name).toBe("my-server")
expect(result.toolUses[0].params.tool_name).toBe("my-tool")
expect(result.toolUses[0].params.arguments).toBe('{"key": "value"}')
})
it("should ignore unknown parameter names within tool calls", () => {
const text = `<read_file>
<path>test.ts</path>
<unknown_param>should be ignored</unknown_param>
</read_file>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].params.path).toBe("test.ts")
expect(Object.keys(result.toolUses[0].params)).toHaveLength(1)
})
it("should handle whitespace in parameter values", () => {
const text = `<read_file>
<path> src/main.ts </path>
</read_file>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
// Whitespace in params is preserved as-is (tool handlers may trim)
expect(result.toolUses[0].params.path).toBe(" src/main.ts ")
})
it("should handle execute_command with cwd parameter", () => {
const text = `<execute_command>
<command>ls -la</command>
<cwd>/home/user/project</cwd>
</execute_command>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses[0].name).toBe("execute_command")
expect(result.toolUses[0].params.command).toBe("ls -la")
expect(result.toolUses[0].params.cwd).toBe("/home/user/project")
})
it("should handle null/undefined input gracefully", () => {
expect(parseXmlToolCalls(null as unknown as string).found).toBe(false)
expect(parseXmlToolCalls(undefined as unknown as string).found).toBe(false)
})
it("should handle codebase_search tool call", () => {
const text = `<codebase_search>
<query>authentication middleware</query>
<path>src</path>
</codebase_search>`
const result = parseXmlToolCalls(text)
expect(result.found).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("codebase_search")
expect(result.toolUses[0].params.query).toBe("authentication middleware")
expect(result.toolUses[0].params.path).toBe("src")
})
})
})

View file

@ -105,6 +105,7 @@ import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { RooProtectedController } from "../protect/RooProtectedController"
import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message"
import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser"
import { parseXmlToolCalls } from "../assistant-message/XmlToolCallFallbackParser"
import { manageContext, willManageContext } from "../context-management"
import { ClineProvider } from "../webview/ClineProvider"
import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
@ -3425,10 +3426,45 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Check if we have any content to process (text or tool uses)
const hasTextContent = assistantMessage.length > 0
const hasToolUses = this.assistantMessageContent.some(
let hasToolUses = this.assistantMessageContent.some(
(block) => block.type === "tool_use" || block.type === "mcp_tool_use",
)
// XML tool call fallback: When the model doesn't support native function calling
// (common with some OpenAI-compatible proxies), it may output tool calls as XML
// text. If we have text content but no native tool uses, try to parse XML tool
// calls from the text as a fallback. (See: GitHub issue #11187)
if (hasTextContent && !hasToolUses) {
const fallbackResult = parseXmlToolCalls(assistantMessage)
if (fallbackResult.found) {
console.log(
`[Task#${this.taskId}] XML tool call fallback: parsed ${fallbackResult.toolUses.length} tool(s) from text`,
)
// Replace the text block(s) with the parsed tool uses
// Keep only non-text blocks (if any) from the original content
this.assistantMessageContent = this.assistantMessageContent.filter(
(block) => block.type !== "text",
)
// Add the parsed tool uses
for (const toolUse of fallbackResult.toolUses) {
this.assistantMessageContent.push(toolUse)
}
// Present each tool call so they are processed by presentAssistantMessage
this.userMessageContentReady = false
for (const toolUse of fallbackResult.toolUses) {
presentAssistantMessage(this)
}
// Re-check hasToolUses now that we've added fallback-parsed tools
hasToolUses = this.assistantMessageContent.some(
(block) => block.type === "tool_use" || block.type === "mcp_tool_use",
)
}
}
if (hasTextContent || hasToolUses) {
// Reset counter when we get a successful response with content
this.consecutiveNoAssistantMessagesCount = 0