From c3e2ca41a6563effbe84f3c663d93da8ec340d4c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 5 Feb 2026 17:26:46 +0000 Subject: [PATCH] feat: add support for XML-style tool calls from Qwen3-Coder-Next This adds support for models like Qwen3-Coder-Next that output XML-style tool calls instead of native JSON tool calls when running via llama.cpp. XML format supported: value Changes: - Add XmlToolCallParser to detect and parse XML tool calls in text stream - Integrate parser into Task.ts text chunk processing - Convert XML tool calls to standard tool_call events for execution - Add comprehensive tests for the parser Fixes #11219 --- .../assistant-message/XmlToolCallParser.ts | 373 ++++++++++++++++++ .../__tests__/XmlToolCallParser.spec.ts | 258 ++++++++++++ src/core/task/Task.ts | 55 ++- 3 files changed, 672 insertions(+), 14 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 0000000000..d7f5f5e0f4 --- /dev/null +++ b/src/core/assistant-message/XmlToolCallParser.ts @@ -0,0 +1,373 @@ +/** + * Parser for XML-style tool calls from models like Qwen3-Coder-Next. + * + * Some models (especially local models running via llama.cpp) output XML-style tool calls + * instead of native JSON tool calls. This parser detects and converts them to the same + * tool call events (tool_call_start/delta/end) that native tool calling uses. + * + * Example XML format: + * ``` + * + * src/main.ts + * + * ``` + * + * Or with equals signs inside parameter values: + * ``` + * + * Task completed successfully + * + * ``` + */ + +import type { + ApiStreamToolCallStartChunk, + ApiStreamToolCallDeltaChunk, + ApiStreamToolCallEndChunk, +} from "../../api/transform/stream" + +export type XmlToolCallEvent = ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk + +/** + * State for tracking an in-progress XML tool call during streaming. + */ +interface XmlToolCallState { + id: string + name: string + parameters: Record + hasStarted: boolean + buffer: string +} + +/** + * Result of processing text through the XML tool call parser. + */ +export interface XmlToolCallParseResult { + /** Text content that is NOT part of a tool call (to be displayed to user) */ + textContent: string + /** Tool call events to be processed */ + events: XmlToolCallEvent[] + /** Whether we're currently inside an incomplete tool call (for streaming) */ + isPartialToolCall: boolean +} + +/** + * Parser for XML-style tool calls. + * + * This parser maintains state across multiple text chunks to handle streaming scenarios + * where a tool call may be split across multiple chunks. + */ +export class XmlToolCallParser { + private static toolCallCounter = 0 + + /** Buffer for accumulating text that might be part of a tool call */ + private buffer: string = "" + + /** Current in-progress tool call state */ + private currentToolCall: XmlToolCallState | null = null + + /** Track if we've detected the start of a potential tool call */ + private potentialToolCallStart: boolean = false + + /** + * Generate a unique ID for XML tool calls. + * Uses a prefix to distinguish from native tool call IDs. + */ + private static generateToolCallId(): string { + return `xml_tool_${Date.now()}_${++this.toolCallCounter}` + } + + /** + * Check if text contains an XML tool call pattern. + * Returns true if the text contains a complete or partial tool call. + */ + public static containsXmlToolCall(text: string): boolean { + // Check for complete function tag + if (//.test(text)) { + return true + } + // Check for start of function tag (partial) + if (/ 0) { + // If we're inside a tool call, look for the end + if (this.currentToolCall) { + const result = this.processInsideToolCall() + events.push(...result.events) + if (result.completed) { + this.currentToolCall = null + } else { + // Tool call is incomplete, wait for more data + break + } + } else { + // Look for the start of a tool call + const functionMatch = this.buffer.match(//) + + if (functionMatch) { + const matchIndex = functionMatch.index! + const matchEnd = matchIndex + functionMatch[0].length + + // Output any text before the tool call + if (matchIndex > 0) { + textContent += this.buffer.substring(0, matchIndex) + } + + // Start a new tool call + const toolName = functionMatch[1] + const toolId = XmlToolCallParser.generateToolCallId() + + this.currentToolCall = { + id: toolId, + name: toolName, + parameters: {}, + hasStarted: false, + buffer: "", + } + + // Remove processed content from buffer + this.buffer = this.buffer.substring(matchEnd) + + // Emit start event + events.push({ + type: "tool_call_start", + id: toolId, + name: toolName, + }) + this.currentToolCall.hasStarted = true + } else if ( + this.buffer.includes(" 0) { + // Check if it looks like it could be a function tag + const afterBracket = this.buffer.substring(potentialStart) + if ( + afterBracket === "<" || + afterBracket.startsWith("")) { + this.potentialToolCallStart = true + break + } + // Not a tool call pattern, output as text + textContent += this.buffer + this.buffer = "" + } else { + // No tool call found, output all text + textContent += this.buffer + this.buffer = "" + } + } + } + + return { + textContent, + events, + isPartialToolCall: this.currentToolCall !== null || this.potentialToolCallStart, + } + } + + /** + * Process content inside a tool call, looking for parameters and the closing tag. + */ + private processInsideToolCall(): { events: XmlToolCallEvent[]; completed: boolean } { + const events: XmlToolCallEvent[] = [] + + if (!this.currentToolCall) { + return { events, completed: true } + } + + // Look for the closing tag + const closingMatch = this.buffer.match(/<\/function>/) + + if (closingMatch) { + const closingIndex = closingMatch.index! + + // Extract content before closing tag + const content = this.buffer.substring(0, closingIndex) + + // Parse parameters from content + this.parseParameters(content) + + // Build the arguments JSON + const argsJson = JSON.stringify(this.currentToolCall.parameters) + + // Emit delta with the arguments + events.push({ + type: "tool_call_delta", + id: this.currentToolCall.id, + delta: argsJson, + }) + + // Emit end event + events.push({ + type: "tool_call_end", + id: this.currentToolCall.id, + }) + + // Remove processed content from buffer (including closing tag) + this.buffer = this.buffer.substring(closingIndex + "".length) + + return { events, completed: true } + } + + // Check if we have a partial closing tag at the end + if ( + this.buffer.endsWith("<") || + this.buffer.endsWith("") + ) { + // Wait for more data + return { events, completed: false } + } + + // No closing tag found, keep waiting + return { events, completed: false } + } + + /** + * Parse parameter tags from content. + * Format: value + */ + private parseParameters(content: string): void { + if (!this.currentToolCall) { + return + } + + // Match all parameter tags + const paramRegex = /([\s\S]*?)<\/parameter>/g + let match + + while ((match = paramRegex.exec(content)) !== null) { + const paramName = match[1] + const paramValue = match[2].trim() + this.currentToolCall.parameters[paramName] = paramValue + } + } + + /** + * Finalize parsing and return any remaining content. + * Call this at the end of a stream to handle any incomplete tool calls. + */ + public finalize(): XmlToolCallParseResult { + const events: XmlToolCallEvent[] = [] + + // If we have an incomplete tool call, try to complete it or emit as text + if (this.currentToolCall) { + // Check if we have a closing tag in the buffer + if (this.buffer.includes("")) { + const result = this.processInsideToolCall() + events.push(...result.events) + } else { + // Incomplete tool call - emit end event with what we have + const argsJson = JSON.stringify(this.currentToolCall.parameters) + events.push({ + type: "tool_call_delta", + id: this.currentToolCall.id, + delta: argsJson, + }) + events.push({ + type: "tool_call_end", + id: this.currentToolCall.id, + }) + } + this.currentToolCall = null + } + + // Return any remaining buffer as text + const textContent = this.buffer + this.buffer = "" + this.potentialToolCallStart = false + + return { + textContent, + events, + isPartialToolCall: false, + } + } + + /** + * Reset parser state. + */ + public reset(): void { + this.buffer = "" + this.currentToolCall = null + this.potentialToolCallStart = false + } + + /** + * Check if parser has pending content. + */ + public hasPendingContent(): boolean { + return this.buffer.length > 0 || this.currentToolCall !== null + } + + /** + * Static utility method to parse a complete text block for XML tool calls. + * Use this for non-streaming scenarios. + * + * @param text - Complete text to parse + * @returns Parse result with text content and tool call events + */ + public static parseComplete(text: string): XmlToolCallParseResult { + const parser = new XmlToolCallParser() + const chunkResult = parser.processChunk(text) + const finalResult = parser.finalize() + + return { + textContent: chunkResult.textContent + finalResult.textContent, + events: [...chunkResult.events, ...finalResult.events], + isPartialToolCall: false, + } + } +} 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 0000000000..96bc3690fd --- /dev/null +++ b/src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts @@ -0,0 +1,258 @@ +import { XmlToolCallParser } from "../XmlToolCallParser" + +describe("XmlToolCallParser", () => { + describe("containsXmlToolCall", () => { + it("should detect complete function tags", () => { + expect(XmlToolCallParser.containsXmlToolCall("")).toBe(true) + expect(XmlToolCallParser.containsXmlToolCall("")).toBe(true) + expect(XmlToolCallParser.containsXmlToolCall("some text more text")).toBe(true) + }) + + it("should detect partial function tags", () => { + expect(XmlToolCallParser.containsXmlToolCall(" { + expect(XmlToolCallParser.containsXmlToolCall("regular text")).toBe(false) + expect(XmlToolCallParser.containsXmlToolCall("some tag")).toBe(false) + expect(XmlToolCallParser.containsXmlToolCall("let x = 5 < 10")).toBe(false) + }) + }) + + describe("parseComplete", () => { + it("should parse a simple tool call", () => { + const text = ` +src/main.ts +` + + const result = XmlToolCallParser.parseComplete(text) + + expect(result.textContent.trim()).toBe("") + expect(result.events).toHaveLength(3) // start, delta, end + + const startEvent = result.events.find((e) => e.type === "tool_call_start") + expect(startEvent).toBeDefined() + expect(startEvent!.type).toBe("tool_call_start") + expect((startEvent as any).name).toBe("read_file") + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + expect(deltaEvent).toBeDefined() + const deltaArgs = JSON.parse((deltaEvent as any).delta) + expect(deltaArgs.path).toBe("src/main.ts") + + const endEvent = result.events.find((e) => e.type === "tool_call_end") + expect(endEvent).toBeDefined() + }) + + it("should parse tool call with multiple parameters", () => { + const text = ` +src/app.ts +const x = 1 +const x = 2 +` + + const result = XmlToolCallParser.parseComplete(text) + + expect(result.events).toHaveLength(3) + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + const args = JSON.parse((deltaEvent as any).delta) + expect(args.path).toBe("src/app.ts") + expect(args.old_string).toBe("const x = 1") + expect(args.new_string).toBe("const x = 2") + }) + + it("should extract text before tool call", () => { + const text = `Here is my analysis: + + +test.txt +` + + const result = XmlToolCallParser.parseComplete(text) + + expect(result.textContent.trim()).toBe("Here is my analysis:") + expect(result.events).toHaveLength(3) + }) + + it("should handle attempt_completion correctly", () => { + const text = ` +Task completed successfully. I have analyzed the code and found no issues. +` + + const result = XmlToolCallParser.parseComplete(text) + + expect(result.events).toHaveLength(3) + + const startEvent = result.events.find((e) => e.type === "tool_call_start") + expect((startEvent as any).name).toBe("attempt_completion") + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + const args = JSON.parse((deltaEvent as any).delta) + expect(args.result).toBe("Task completed successfully. I have analyzed the code and found no issues.") + }) + + it("should handle multiline parameter values", () => { + const text = ` +test.ts +function hello() { + console.log("Hello, World!"); +} + +` + + const result = XmlToolCallParser.parseComplete(text) + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + const args = JSON.parse((deltaEvent as any).delta) + expect(args.path).toBe("test.ts") + expect(args.content).toContain('console.log("Hello, World!")') + }) + + it("should pass through text without tool calls", () => { + const text = "This is just regular text without any tool calls." + + const result = XmlToolCallParser.parseComplete(text) + + expect(result.textContent).toBe(text) + expect(result.events).toHaveLength(0) + }) + }) + + describe("streaming (processChunk)", () => { + it("should handle tool call split across chunks", () => { + const parser = new XmlToolCallParser() + + // Send chunks progressively - start with a recognizable partial pattern + const result1 = parser.processChunk("") + expect(result2.events.some((e) => e.type === "tool_call_start")).toBe(true) + + const result3 = parser.processChunk("test.ts") + // Should have delta and end events + const allEvents = [...result4.events] + expect(allEvents.some((e) => e.type === "tool_call_delta")).toBe(true) + expect(allEvents.some((e) => e.type === "tool_call_end")).toBe(true) + }) + + it("should accumulate text before tool call in streaming", () => { + const parser = new XmlToolCallParser() + + const result1 = parser.processChunk("Some text ") + expect(result1.textContent).toBe("Some text ") + expect(result1.events).toHaveLength(0) + + const result2 = parser.processChunk("before ") + expect(result2.textContent).toBe("before ") + expect(result2.events.some((e) => e.type === "tool_call_start")).toBe(true) + }) + + it("should finalize incomplete tool calls", () => { + const parser = new XmlToolCallParser() + + parser.processChunk("") + parser.processChunk("test.ts") + + // Finalize without closing tag + const finalResult = parser.finalize() + + // Should still emit delta and end events + expect(finalResult.events.some((e) => e.type === "tool_call_delta")).toBe(true) + expect(finalResult.events.some((e) => e.type === "tool_call_end")).toBe(true) + }) + + it("should reset state correctly", () => { + const parser = new XmlToolCallParser() + + parser.processChunk("") + expect(parser.hasPendingContent()).toBe(true) + + parser.reset() + expect(parser.hasPendingContent()).toBe(false) + }) + }) + + describe("edge cases", () => { + it("should handle empty parameter values", () => { + const text = ` + +` + + const result = XmlToolCallParser.parseComplete(text) + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + const args = JSON.parse((deltaEvent as any).delta) + expect(args.path).toBe("") + }) + + it("should handle parameter values with special characters", () => { + const text = ` +echo "Hello " +` + + const result = XmlToolCallParser.parseComplete(text) + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + const args = JSON.parse((deltaEvent as any).delta) + expect(args.command).toBe('echo "Hello "') + }) + + it("should generate unique tool call IDs", () => { + const result1 = XmlToolCallParser.parseComplete( + "a.ts", + ) + const result2 = XmlToolCallParser.parseComplete( + "b.ts", + ) + + const id1 = (result1.events.find((e) => e.type === "tool_call_start") as any).id + const id2 = (result2.events.find((e) => e.type === "tool_call_start") as any).id + + expect(id1).not.toBe(id2) + expect(id1).toMatch(/^xml_tool_/) + expect(id2).toMatch(/^xml_tool_/) + }) + + it("should handle Qwen3-Coder-Next exact format", () => { + // This is the exact format from the issue + const text = `I am Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. I can analyze code, explain concepts, and access external resources to help you with technical questions. + + +I am Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. I can analyze code, explain concepts, and access external resources to help you with technical questions. +` + + const result = XmlToolCallParser.parseComplete(text) + + expect(result.textContent.trim()).toContain("I am Roo, a knowledgeable technical assistant") + expect(result.events).toHaveLength(3) + + const startEvent = result.events.find((e) => e.type === "tool_call_start") + expect((startEvent as any).name).toBe("attempt_completion") + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + const args = JSON.parse((deltaEvent as any).delta) + expect(args.result).toContain("I am Roo, a knowledgeable technical assistant") + }) + + it("should handle tool calls with no parameters", () => { + const text = ` +` + + const result = XmlToolCallParser.parseComplete(text) + + expect(result.events).toHaveLength(3) + + const deltaEvent = result.events.find((e) => e.type === "tool_call_delta") + const args = JSON.parse((deltaEvent as any).delta) + expect(Object.keys(args)).toHaveLength(0) + }) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 107cfdf9e9..33e11bf0a7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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 { XmlToolCallParser } from "../assistant-message/XmlToolCallParser" import { manageContext, willManageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" @@ -522,6 +523,9 @@ export class Task extends EventEmitter implements TaskLike { // Native tool call streaming state (track which index each tool is at) private streamingToolCallIndices: Map = new Map() + // XML-style tool call parser for models like Qwen3-Coder-Next that output XML tool calls + private xmlToolCallParser: XmlToolCallParser = new XmlToolCallParser() + // Cached model info for current streaming session (set at start of each API request) // This prevents excessive getModel() calls during tool execution cachedStreamingModel?: { id: string; info: ModelInfo } @@ -2881,6 +2885,8 @@ export class Task extends EventEmitter implements TaskLike { // Clear any leftover streaming tool call state from previous interrupted streams NativeToolCallParser.clearAllStreamingToolCalls() NativeToolCallParser.clearRawChunkState() + // Reset XML tool call parser for models that output XML-style tool calls + this.xmlToolCallParser.reset() await this.diffViewProvider.reset() @@ -3020,22 +3026,36 @@ export class Task extends EventEmitter implements TaskLike { break } case "text": { - assistantMessage += chunk.text + // Check for XML-style tool calls in the text (e.g., from Qwen3-Coder-Next) + // These models output VALUE + // instead of native JSON tool calls + const xmlResult = this.xmlToolCallParser.processChunk(chunk.text) - // Native tool calling: text chunks are plain text. - // Create or update a text content block directly - const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1] - if (lastBlock?.type === "text" && lastBlock.partial) { - lastBlock.content = assistantMessage - } else { - this.assistantMessageContent.push({ - type: "text", - content: assistantMessage, - partial: true, - }) - this.userMessageContentReady = false + // Process any XML tool call events + for (const event of xmlResult.events) { + this.handleToolCallEvent(event) + } + + // Only accumulate non-tool-call text + if (xmlResult.textContent) { + assistantMessage += xmlResult.textContent + + // Native tool calling: text chunks are plain text. + // Create or update a text content block directly + const lastBlock = + this.assistantMessageContent[this.assistantMessageContent.length - 1] + if (lastBlock?.type === "text" && lastBlock.partial) { + lastBlock.content = assistantMessage + } else { + this.assistantMessageContent.push({ + type: "text", + content: assistantMessage, + partial: true, + }) + this.userMessageContentReady = false + } + presentAssistantMessage(this) } - presentAssistantMessage(this) break } } @@ -3372,6 +3392,13 @@ export class Task extends EventEmitter implements TaskLike { } } + // Finalize XML tool call parser for models like Qwen3-Coder-Next + // This handles any incomplete tool calls at the end of the stream + const xmlFinalResult = this.xmlToolCallParser.finalize() + for (const event of xmlFinalResult.events) { + this.handleToolCallEvent(event) + } + // IMPORTANT: Capture partialBlocks AFTER finalizeRawChunks() to avoid double-presentation. // Tools finalized above are already presented, so we only want blocks still partial after finalization. const partialBlocks = this.assistantMessageContent.filter((block) => block.partial)