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(`${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: ...
+ // 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 {
+ 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
+ }
}
}