feat: add XML tool call fallback for OpenAI-compatible providers

Some API providers (like kie.ai with Gemini 3 Pro) do not fully support
native function calling and instead output XML-formatted tool calls in
text responses. This change adds support for parsing these XML tool calls
and executing them as a fallback.

Changes:
- Add XmlToolCallParser to parse XML tool calls from text content
- Modify presentAssistantMessage to detect and parse XML tool calls
  instead of rejecting them with an error
- Add comprehensive tests for the XmlToolCallParser

Fixes #11011
This commit is contained in:
Roo Code 2026-01-27 18:33:16 +00:00
parent b9cf163b87
commit f39fad586e
3 changed files with 986 additions and 10 deletions

View file

@ -0,0 +1,505 @@
import { v4 as uuidv4 } from "uuid"
import { type ToolName, toolNames } from "@roo-code/types"
import { type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools"
import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode"
/**
* List of tool names that can be parsed from XML format.
* These match the tools in containsXmlToolMarkup() in presentAssistantMessage.ts
*/
const XML_TOOL_NAMES = [
"access_mcp_resource",
"apply_diff",
"apply_patch",
"ask_followup_question",
"attempt_completion",
"browser_action",
"codebase_search",
"edit_file",
"execute_command",
"fetch_instructions",
"generate_image",
"list_files",
"new_task",
"read_file",
"search_and_replace",
"search_files",
"search_replace",
"switch_mode",
"update_todo_list",
"use_mcp_tool",
"write_to_file",
] as const
/**
* Result of parsing XML tool calls from text.
*/
export interface XmlToolCallParseResult {
/** The tool use objects extracted from the text */
toolUses: ToolUse[]
/** Any text content that was not part of a tool call */
remainingText: string
/** Whether any tool calls were found */
hasToolCalls: boolean
}
/**
* Parser for XML-formatted tool calls.
*
* Some API providers (like kie.ai with Gemini 3 Pro) don't fully support
* native function calling and instead output XML-formatted tool calls in
* the text response. This parser extracts those tool calls and converts
* them to the ToolUse format used by the existing tool execution infrastructure.
*
* Example XML format:
* ```
* <read_file>
* <path>src/file.ts</path>
* </read_file>
* ```
*/
export class XmlToolCallParser {
/**
* Generate a unique tool call ID for native protocol compatibility.
* Uses the format "toolu_" prefix followed by a UUID to match Anthropic's format.
*/
private static generateToolCallId(): string {
return `toolu_${uuidv4().replace(/-/g, "").substring(0, 24)}`
}
/**
* Check if text contains XML tool markup (outside of code blocks).
*/
public static containsXmlToolMarkup(text: string): boolean {
// Strip code blocks first to avoid false positives
const textWithoutCodeBlocks = text
.replace(/```[\s\S]*?```/g, "") // Remove fenced code blocks
.replace(/`[^`]+`/g, "") // Remove inline code
const lower = textWithoutCodeBlocks.toLowerCase()
if (!lower.includes("<") || !lower.includes(">")) {
return false
}
return XML_TOOL_NAMES.some((name) => lower.includes(`<${name}`) || lower.includes(`</${name}`))
}
/**
* Parse XML tool calls from text content.
*
* @param text - The text content potentially containing XML tool calls
* @returns Parse result with extracted tool uses and remaining text
*/
public static parseXmlToolCalls(text: string): XmlToolCallParseResult {
// Collect all matches with their positions to maintain document order
const matches: Array<{
position: number
fullMatch: string
innerContent: string
toolName: string
}> = []
// Find all tool matches across all tool names
for (const toolName of XML_TOOL_NAMES) {
// Pattern to match complete tool tags: <tool_name>...</tool_name>
// Uses a non-greedy match for content to handle multiple tool calls
const regex = new RegExp(`<${toolName}>([\\s\\S]*?)</${toolName}>`, "gi")
let match
while ((match = regex.exec(text)) !== null) {
matches.push({
position: match.index,
fullMatch: match[0],
innerContent: match[1],
toolName,
})
}
}
// Sort matches by position to maintain document order
matches.sort((a, b) => a.position - b.position)
// Process matches in document order
const toolUses: ToolUse[] = []
let remainingText = text
for (const match of matches) {
// Parse the inner XML parameters
const params = this.parseToolParams(match.innerContent)
// Resolve tool alias to canonical name
const resolvedName = resolveToolAlias(match.toolName) as ToolName
// Create the ToolUse object
const toolUse = this.createToolUse(resolvedName, params, match.toolName)
if (toolUse) {
toolUses.push(toolUse)
}
// Remove the matched tool call from remaining text
remainingText = remainingText.replace(match.fullMatch, "")
}
// Clean up remaining text - trim and remove multiple consecutive newlines
remainingText = remainingText.replace(/\n{3,}/g, "\n\n").trim()
return {
toolUses,
remainingText,
hasToolCalls: toolUses.length > 0,
}
}
/**
* Parse parameter tags from XML tool content.
* Handles both simple text content and nested structures.
*
* @param content - The inner content of a tool tag
* @returns Map of parameter names to their values
*/
private static parseToolParams(content: string): Map<string, string> {
const params = new Map<string, string>()
// Match parameter tags: <param_name>value</param_name>
// Also handles CDATA sections and multi-line content
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/g
let match
while ((match = paramRegex.exec(content)) !== null) {
const paramName = match[1]
let paramValue = match[2]
// Handle CDATA sections
const cdataMatch = paramValue.match(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/)
if (cdataMatch) {
paramValue = cdataMatch[1]
} else {
// Trim whitespace for regular values
paramValue = paramValue.trim()
}
params.set(paramName, paramValue)
}
return params
}
/**
* Create a ToolUse object from parsed parameters.
*
* @param name - The canonical tool name
* @param params - Map of parameter names to values
* @param originalName - The original tool name if it was an alias
* @returns ToolUse object or null if parameters are invalid
*/
private static createToolUse(name: ToolName, params: Map<string, string>, originalName?: string): ToolUse | null {
// Validate tool name
if (!toolNames.includes(name)) {
console.warn(`[XmlToolCallParser] Unknown tool name: ${name}`)
return null
}
// Convert params Map to the record format expected by ToolUse
const paramsRecord: Partial<Record<ToolParamName, string>> = {}
for (const [key, value] of params.entries()) {
if (toolParamNames.includes(key as ToolParamName)) {
paramsRecord[key as ToolParamName] = value
}
}
// Build nativeArgs based on tool type
const nativeArgs = this.buildNativeArgs(name, params)
const toolUse: ToolUse = {
type: "tool_use",
id: this.generateToolCallId(),
name,
params: paramsRecord,
partial: false,
nativeArgs,
}
// Preserve original name if it was an alias
if (originalName && originalName !== name) {
toolUse.originalName = originalName
}
return toolUse
}
/**
* Build typed nativeArgs for a tool based on its parameters.
* This mirrors the logic in NativeToolCallParser.parseToolCall().
*
* @param name - The tool name
* @param params - Map of parameter names to string values
* @returns Typed nativeArgs object or undefined
*/
private static buildNativeArgs(name: ToolName, params: Map<string, string>): any {
// Helper to safely get and parse JSON values
const getJson = (key: string): any => {
const value = params.get(key)
if (!value) {
return undefined
}
try {
return JSON.parse(value)
} catch {
return value
}
}
const get = (key: string): string | undefined => params.get(key)
const getBool = (key: string): boolean | undefined => {
const value = params.get(key)
if (value === undefined) {
return undefined
}
return value.toLowerCase() === "true"
}
switch (name) {
case "read_file": {
// For XML format, files is typically a path string, not an array
const path = get("path")
if (path) {
return { files: [{ path }] }
}
// Try parsing as JSON array if provided
const files = getJson("files")
if (Array.isArray(files)) {
return { files }
}
return undefined
}
case "attempt_completion": {
const result = get("result")
if (result !== undefined) {
return { result }
}
return undefined
}
case "execute_command": {
const command = get("command")
if (command) {
return {
command,
cwd: get("cwd"),
}
}
return undefined
}
case "write_to_file": {
const path = get("path")
const content = get("content")
if (path !== undefined && content !== undefined) {
return { path, content }
}
return undefined
}
case "apply_diff": {
const path = get("path")
const diff = get("diff")
if (path !== undefined && diff !== undefined) {
return { path, diff }
}
return undefined
}
case "search_and_replace": {
const path = get("path")
const operations = getJson("operations")
if (path !== undefined && Array.isArray(operations)) {
return { path, operations }
}
return undefined
}
case "ask_followup_question": {
const question = get("question")
const follow_up = getJson("follow_up")
if (question !== undefined && follow_up !== undefined) {
return { question, follow_up }
}
return undefined
}
case "browser_action": {
const action = get("action")
if (action !== undefined) {
return {
action,
url: get("url"),
coordinate: get("coordinate"),
size: get("size"),
text: get("text"),
path: get("path"),
}
}
return undefined
}
case "codebase_search": {
const query = get("query")
if (query !== undefined) {
return {
query,
path: get("path"),
}
}
return undefined
}
case "fetch_instructions": {
const task = get("task")
if (task !== undefined) {
return { task }
}
return undefined
}
case "generate_image": {
const prompt = get("prompt")
const path = get("path")
if (prompt !== undefined && path !== undefined) {
return {
prompt,
path,
image: get("image"),
}
}
return undefined
}
case "run_slash_command": {
const command = get("command")
if (command !== undefined) {
return {
command,
args: get("args"),
}
}
return undefined
}
case "search_files": {
const path = get("path")
const regex = get("regex")
if (path !== undefined && regex !== undefined) {
return {
path,
regex,
file_pattern: get("file_pattern"),
}
}
return undefined
}
case "switch_mode": {
const mode_slug = get("mode_slug")
const reason = get("reason")
if (mode_slug !== undefined && reason !== undefined) {
return { mode_slug, reason }
}
return undefined
}
case "update_todo_list": {
const todos = get("todos")
if (todos !== undefined) {
return { todos }
}
return undefined
}
case "use_mcp_tool": {
const server_name = get("server_name")
const tool_name = get("tool_name")
if (server_name !== undefined && tool_name !== undefined) {
return {
server_name,
tool_name,
arguments: getJson("arguments"),
}
}
return undefined
}
case "access_mcp_resource": {
const server_name = get("server_name")
const uri = get("uri")
if (server_name !== undefined && uri !== undefined) {
return { server_name, uri }
}
return undefined
}
case "apply_patch": {
const patch = get("patch")
if (patch !== undefined) {
return { patch }
}
return undefined
}
case "search_replace": {
const file_path = get("file_path")
const old_string = get("old_string")
const new_string = get("new_string")
if (file_path !== undefined && old_string !== undefined && new_string !== undefined) {
return { file_path, old_string, new_string }
}
return undefined
}
case "edit_file": {
const file_path = get("file_path")
const old_string = get("old_string")
const new_string = get("new_string")
if (file_path !== undefined && old_string !== undefined && new_string !== undefined) {
return {
file_path,
old_string,
new_string,
expected_replacements: getJson("expected_replacements"),
}
}
return undefined
}
case "list_files": {
const path = get("path")
if (path !== undefined) {
return {
path,
recursive: getBool("recursive"),
}
}
return undefined
}
case "new_task": {
const mode = get("mode")
const message = get("message")
if (mode !== undefined && message !== undefined) {
return {
mode,
message,
todos: get("todos"),
}
}
return undefined
}
default:
return undefined
}
}
}

View file

@ -0,0 +1,452 @@
import { XmlToolCallParser } from "../XmlToolCallParser"
describe("XmlToolCallParser", () => {
describe("containsXmlToolMarkup", () => {
it("should detect read_file tool markup", () => {
const text = `<read_file>
<path>src/file.ts</path>
</read_file>`
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
})
it("should detect execute_command tool markup", () => {
const text = `<execute_command>
<command>npm test</command>
</execute_command>`
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
})
it("should detect write_to_file tool markup", () => {
const text = `<write_to_file>
<path>test.ts</path>
<content>console.log('hello')</content>
</write_to_file>`
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
})
it("should not detect tool markup in code blocks", () => {
const text = "```\n<read_file><path>src/file.ts</path></read_file>\n```"
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false)
})
it("should not detect tool markup in inline code", () => {
const text = "Use `<read_file><path>src/file.ts</path></read_file>` to read files"
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false)
})
it("should not detect non-tool XML tags", () => {
const text = "<div>Hello</div><span>World</span>"
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false)
})
it("should return false for plain text", () => {
const text = "This is just plain text without any XML"
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false)
})
it("should be case insensitive", () => {
const text = "<READ_FILE><path>src/file.ts</path></READ_FILE>"
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
})
})
describe("parseXmlToolCalls", () => {
describe("read_file tool", () => {
it("should parse simple read_file tool call", () => {
const text = `<read_file>
<path>src/file.ts</path>
</read_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("read_file")
expect(result.toolUses[0].type).toBe("tool_use")
expect(result.toolUses[0].partial).toBe(false)
expect(result.toolUses[0].id).toMatch(/^toolu_/)
expect(result.toolUses[0].nativeArgs).toEqual({
files: [{ path: "src/file.ts" }],
})
})
it("should handle read_file with files array in params", () => {
const text = `<read_file>
<files>[{"path": "src/file.ts"}]</files>
</read_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].nativeArgs).toEqual({
files: [{ path: "src/file.ts" }],
})
})
})
describe("execute_command tool", () => {
it("should parse execute_command tool call", () => {
const text = `<execute_command>
<command>npm test</command>
</execute_command>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("execute_command")
expect(result.toolUses[0].nativeArgs).toEqual({
command: "npm test",
cwd: undefined,
})
})
it("should parse execute_command with cwd", () => {
const text = `<execute_command>
<command>npm test</command>
<cwd>./packages/core</cwd>
</execute_command>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].nativeArgs).toEqual({
command: "npm test",
cwd: "./packages/core",
})
})
})
describe("write_to_file tool", () => {
it("should parse write_to_file tool call", () => {
const text = `<write_to_file>
<path>test.ts</path>
<content>console.log('hello')</content>
</write_to_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses).toHaveLength(1)
expect(result.toolUses[0].name).toBe("write_to_file")
expect(result.toolUses[0].nativeArgs).toEqual({
path: "test.ts",
content: "console.log('hello')",
})
})
it("should preserve multi-line content", () => {
const text = `<write_to_file>
<path>test.ts</path>
<content>function hello() {
console.log('hello')
}
export { hello }</content>
</write_to_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
const nativeArgs = result.toolUses[0].nativeArgs as { content: string }
expect(nativeArgs.content).toContain("function hello()")
expect(nativeArgs.content).toContain("export { hello }")
})
})
describe("apply_diff tool", () => {
it("should parse apply_diff tool call", () => {
const text = `<apply_diff>
<path>src/file.ts</path>
<diff>--- a/src/file.ts
+++ b/src/file.ts
@@ -1,3 +1,3 @@
-const x = 1
+const x = 2</diff>
</apply_diff>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("apply_diff")
expect(result.toolUses[0].nativeArgs).toHaveProperty("path", "src/file.ts")
expect(result.toolUses[0].nativeArgs).toHaveProperty("diff")
})
})
describe("list_files tool", () => {
it("should parse list_files tool call", () => {
const text = `<list_files>
<path>src/</path>
<recursive>true</recursive>
</list_files>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("list_files")
expect(result.toolUses[0].nativeArgs).toEqual({
path: "src/",
recursive: true,
})
})
it("should handle recursive=false", () => {
const text = `<list_files>
<path>./</path>
<recursive>false</recursive>
</list_files>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
const nativeArgs = result.toolUses[0].nativeArgs as { recursive: boolean }
expect(nativeArgs.recursive).toBe(false)
})
})
describe("search_files tool", () => {
it("should parse search_files tool call", () => {
const text = `<search_files>
<path>src/</path>
<regex>TODO:</regex>
<file_pattern>*.ts</file_pattern>
</search_files>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("search_files")
expect(result.toolUses[0].nativeArgs).toEqual({
path: "src/",
regex: "TODO:",
file_pattern: "*.ts",
})
})
})
describe("attempt_completion tool", () => {
it("should parse attempt_completion tool call", () => {
const text = `<attempt_completion>
<result>The task has been completed successfully.</result>
</attempt_completion>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("attempt_completion")
expect(result.toolUses[0].nativeArgs).toEqual({
result: "The task has been completed successfully.",
})
})
})
describe("ask_followup_question tool", () => {
it("should parse ask_followup_question tool call", () => {
const text = `<ask_followup_question>
<question>What file would you like me to read?</question>
<follow_up>[{"text": "src/file.ts"}, {"text": "package.json"}]</follow_up>
</ask_followup_question>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("ask_followup_question")
const nativeArgs = result.toolUses[0].nativeArgs as { question: string; follow_up: unknown[] }
expect(nativeArgs.question).toBe("What file would you like me to read?")
expect(nativeArgs.follow_up).toEqual([{ text: "src/file.ts" }, { text: "package.json" }])
})
})
describe("switch_mode tool", () => {
it("should parse switch_mode tool call", () => {
const text = `<switch_mode>
<mode_slug>code</mode_slug>
<reason>Need to implement the feature</reason>
</switch_mode>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("switch_mode")
expect(result.toolUses[0].nativeArgs).toEqual({
mode_slug: "code",
reason: "Need to implement the feature",
})
})
})
describe("multiple tool calls", () => {
it("should parse multiple tool calls in sequence", () => {
const text = `I'll first read the file and then execute a command.
<read_file>
<path>src/file.ts</path>
</read_file>
<execute_command>
<command>npm test</command>
</execute_command>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses).toHaveLength(2)
expect(result.toolUses[0].name).toBe("read_file")
expect(result.toolUses[1].name).toBe("execute_command")
expect(result.remainingText).toBe("I'll first read the file and then execute a command.")
})
it("should extract remaining text properly", () => {
const text = `Here's my analysis:
<read_file>
<path>src/file.ts</path>
</read_file>
This should help us understand the code.`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.remainingText).toContain("Here's my analysis:")
expect(result.remainingText).toContain("This should help us understand the code.")
})
})
describe("CDATA handling", () => {
it("should handle CDATA sections in content", () => {
const text = `<write_to_file>
<path>test.ts</path>
<content><![CDATA[const x = 1; // This <should> work]]></content>
</write_to_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
const nativeArgs = result.toolUses[0].nativeArgs as { content: string }
expect(nativeArgs.content).toBe("const x = 1; // This <should> work")
})
})
describe("edge cases", () => {
it("should return empty result for text without tool calls", () => {
const text = "This is just plain text without any tool calls."
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(false)
expect(result.toolUses).toHaveLength(0)
expect(result.remainingText).toBe(text)
})
it("should generate unique tool call IDs", () => {
const text = `<read_file><path>file1.ts</path></read_file>
<read_file><path>file2.ts</path></read_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.toolUses[0].id).not.toBe(result.toolUses[1].id)
expect(result.toolUses[0].id).toMatch(/^toolu_/)
expect(result.toolUses[1].id).toMatch(/^toolu_/)
})
it("should handle empty parameters", () => {
const text = `<attempt_completion>
<result></result>
</attempt_completion>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].nativeArgs).toEqual({ result: "" })
})
it("should handle whitespace in parameters", () => {
const text = `<read_file>
<path> src/file.ts </path>
</read_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
// Whitespace should be trimmed
const nativeArgs = result.toolUses[0].nativeArgs as { files: Array<{ path: string }> }
expect(nativeArgs.files[0].path).toBe("src/file.ts")
})
})
describe("tool alias resolution", () => {
it("should resolve edit_file alias to apply_diff", () => {
// Note: This depends on the resolveToolAlias implementation
// If edit_file maps to apply_diff, we should test that
const text = `<edit_file>
<file_path>src/file.ts</file_path>
<old_string>const x = 1</old_string>
<new_string>const x = 2</new_string>
</edit_file>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
// The resolved name might be different based on alias mapping
expect(result.toolUses[0].nativeArgs).toHaveProperty("file_path", "src/file.ts")
})
})
describe("use_mcp_tool", () => {
it("should parse use_mcp_tool with JSON arguments", () => {
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 = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("use_mcp_tool")
expect(result.toolUses[0].nativeArgs).toEqual({
server_name: "my-server",
tool_name: "my-tool",
arguments: { key: "value" },
})
})
})
describe("browser_action tool", () => {
it("should parse browser_action tool call", () => {
const text = `<browser_action>
<action>launch</action>
<url>https://example.com</url>
</browser_action>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("browser_action")
const nativeArgs = result.toolUses[0].nativeArgs as { action: string; url: string }
expect(nativeArgs.action).toBe("launch")
expect(nativeArgs.url).toBe("https://example.com")
})
})
describe("new_task tool", () => {
it("should parse new_task tool call", () => {
const text = `<new_task>
<mode>architect</mode>
<message>Design the new feature</message>
</new_task>`
const result = XmlToolCallParser.parseXmlToolCalls(text)
expect(result.hasToolCalls).toBe(true)
expect(result.toolUses[0].name).toBe("new_task")
expect(result.toolUses[0].nativeArgs).toEqual({
mode: "architect",
message: "Design the new feature",
todos: undefined,
})
})
})
})
})

View file

@ -7,6 +7,7 @@ import { TelemetryService } from "@roo-code/telemetry"
import { customToolRegistry } from "@roo-code/core"
import { t } from "../../i18n"
import { XmlToolCallParser } from "./XmlToolCallParser"
import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
import type { ToolParamName, ToolResponse, ToolUse, McpToolUse } from "../../shared/tools"
@ -313,16 +314,34 @@ export async function presentAssistantMessage(cline: Task) {
content = content.replace(/<thinking>\s?/g, "")
content = content.replace(/\s?<\/thinking>/g, "")
// Tool calling is native-only. If the model emits XML-style tool tags in a text block,
// fail fast with a clear error.
if (containsXmlToolMarkup(content)) {
const errorMessage =
"XML tool calls are no longer supported. Remove any XML tool markup (e.g. <read_file>...</read_file>) and use native tool calling instead."
cline.consecutiveMistakeCount++
await cline.say("error", errorMessage)
cline.userMessageContent.push({ type: "text", text: errorMessage })
cline.didAlreadyUseTool = true
break
// Check for XML-formatted tool calls in text content.
// Some API providers (like kie.ai with Gemini 3 Pro) output tool calls
// as XML in text rather than using native function calling.
// We parse these and inject them for execution rather than rejecting.
if (!block.partial && containsXmlToolMarkup(content)) {
const parseResult = XmlToolCallParser.parseXmlToolCalls(content)
if (parseResult.hasToolCalls) {
// Show any non-tool text content first
if (parseResult.remainingText.trim()) {
await cline.say("text", parseResult.remainingText.trim(), undefined, false)
}
// Inject parsed tool uses into assistantMessageContent for processing.
// We insert them at the current index + 1 so they get processed next.
const currentIndex = cline.currentStreamingContentIndex
const insertIndex = currentIndex + 1
// Insert the parsed tool uses
cline.assistantMessageContent.splice(insertIndex, 0, ...parseResult.toolUses)
// Log for debugging
console.log(
`[presentAssistantMessage] Parsed ${parseResult.toolUses.length} XML tool call(s) from text block`,
)
break
}
}
}