From f39fad586eb34a04c24bdff06c2684707bff8ae4 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 27 Jan 2026 18:33:16 +0000 Subject: [PATCH] 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 --- .../assistant-message/XmlToolCallParser.ts | 505 ++++++++++++++++++ .../__tests__/XmlToolCallParser.spec.ts | 452 ++++++++++++++++ .../presentAssistantMessage.ts | 39 +- 3 files changed, 986 insertions(+), 10 deletions(-) create mode 100644 src/core/assistant-message/XmlToolCallParser.ts create mode 100644 src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts diff --git a/src/core/assistant-message/XmlToolCallParser.ts b/src/core/assistant-message/XmlToolCallParser.ts new file mode 100644 index 00000000000..9dc9da1e5bc --- /dev/null +++ b/src/core/assistant-message/XmlToolCallParser.ts @@ -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: + * ``` + * + * src/file.ts + * + * ``` + */ +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(` = [] + + // Find all tool matches across all tool names + for (const toolName of XML_TOOL_NAMES) { + // Pattern to match complete tool tags: ... + // Uses a non-greedy match for content to handle multiple tool calls + const regex = new RegExp(`<${toolName}>([\\s\\S]*?)`, "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 { + const params = new Map() + + // Match parameter tags: value + // 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*\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, 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> = {} + 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): 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 + } + } +} diff --git a/src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts b/src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts new file mode 100644 index 00000000000..8c2233a0674 --- /dev/null +++ b/src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts @@ -0,0 +1,452 @@ +import { XmlToolCallParser } from "../XmlToolCallParser" + +describe("XmlToolCallParser", () => { + describe("containsXmlToolMarkup", () => { + it("should detect read_file tool markup", () => { + const text = ` +src/file.ts +` + expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true) + }) + + it("should detect execute_command tool markup", () => { + const text = ` +npm test +` + expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true) + }) + + it("should detect write_to_file tool markup", () => { + const text = ` +test.ts +console.log('hello') +` + expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true) + }) + + it("should not detect tool markup in code blocks", () => { + const text = "```\nsrc/file.ts\n```" + expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false) + }) + + it("should not detect tool markup in inline code", () => { + const text = "Use `src/file.ts` to read files" + expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false) + }) + + it("should not detect non-tool XML tags", () => { + const text = "
Hello
World" + 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 = "src/file.ts" + expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true) + }) + }) + + describe("parseXmlToolCalls", () => { + describe("read_file tool", () => { + it("should parse simple read_file tool call", () => { + const text = ` +src/file.ts +` + + 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 = ` +[{"path": "src/file.ts"}] +` + + 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 = ` +npm test +` + + 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 = ` +npm test +./packages/core +` + + 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 = ` +test.ts +console.log('hello') +` + + 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 = ` +test.ts +function hello() { + console.log('hello') +} + +export { hello } +` + + 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 = ` +src/file.ts +--- a/src/file.ts ++++ b/src/file.ts +@@ -1,3 +1,3 @@ +-const x = 1 ++const x = 2 +` + + 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 = ` +src/ +true +` + + 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 = ` +./ +false +` + + 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 = ` +src/ +TODO: +*.ts +` + + 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 = ` +The task has been completed successfully. +` + + 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 = ` +What file would you like me to read? +[{"text": "src/file.ts"}, {"text": "package.json"}] +` + + 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 = ` +code +Need to implement the feature +` + + 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. + + +src/file.ts + + + +npm test +` + + 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: + + +src/file.ts + + +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 = ` +test.ts + work]]> +` + + 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 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 = `file1.ts +file2.ts` + + 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 = ` + +` + + 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 = ` + src/file.ts +` + + 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 = ` +src/file.ts +const x = 1 +const x = 2 +` + + 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 = ` +my-server +my-tool +{"key": "value"} +` + + 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 = ` +launch +https://example.com +` + + 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 = ` +architect +Design the new feature +` + + 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, + }) + }) + }) + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index b0b330d9907..5440e4b522b 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -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(/\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. ...) 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 + } } }