mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
7fc42d70ea
commit
d12f7877e8
3 changed files with 479 additions and 1 deletions
138
src/core/assistant-message/XmlToolCallFallbackParser.ts
Normal file
138
src/core/assistant-message/XmlToolCallFallbackParser.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue