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:
<function=TOOL_NAME>
<parameter=PARAM_NAME>value</parameter>
</function>

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
This commit is contained in:
Roo Code 2026-02-05 17:26:46 +00:00
parent 934f34ea87
commit c3e2ca41a6
3 changed files with 672 additions and 14 deletions

View file

@ -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:
* ```
* <function=read_file>
* <parameter=path>src/main.ts</parameter>
* </function>
* ```
*
* Or with equals signs inside parameter values:
* ```
* <function=attempt_completion>
* <parameter=result>Task completed successfully</parameter>
* </function>
* ```
*/
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<string, string>
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 (/<function=\w+>/.test(text)) {
return true
}
// Check for start of function tag (partial)
if (/<function=/.test(text) || /<function$/.test(text)) {
return true
}
// Check for opening angle bracket that might be start of a tag
if (/<$/.test(text)) {
return true
}
return false
}
/**
* Process a text chunk and extract any XML tool calls.
*
* @param text - The text chunk to process
* @returns Parse result with text content and tool call events
*/
public processChunk(text: string): XmlToolCallParseResult {
const events: XmlToolCallEvent[] = []
let textContent = ""
// Add new text to buffer
this.buffer += text
// Process the buffer
while (this.buffer.length > 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(/<function=(\w+)>/)
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("<function=") ||
this.buffer.endsWith("<") ||
this.buffer.endsWith("<f")
) {
// Potential partial tool call start - wait for more data
// But first check if we have any complete text before the potential start
const potentialStart = this.buffer.lastIndexOf("<")
if (potentialStart > 0) {
// Check if it looks like it could be a function tag
const afterBracket = this.buffer.substring(potentialStart)
if (
afterBracket === "<" ||
afterBracket.startsWith("<f") ||
afterBracket.startsWith("<fu") ||
afterBracket.startsWith("<fun") ||
afterBracket.startsWith("<func") ||
afterBracket.startsWith("<funct") ||
afterBracket.startsWith("<functi") ||
afterBracket.startsWith("<functio") ||
afterBracket.startsWith("<function") ||
afterBracket.startsWith("<function=")
) {
textContent += this.buffer.substring(0, potentialStart)
this.buffer = afterBracket
this.potentialToolCallStart = true
break
}
}
// If we get here with a partial <function= at the start, wait for more
if (this.buffer.startsWith("<function=") && !this.buffer.includes(">")) {
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 </function> 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 + "</function>".length)
return { events, completed: true }
}
// Check if we have a partial closing tag at the end
if (
this.buffer.endsWith("<") ||
this.buffer.endsWith("</") ||
this.buffer.endsWith("</f") ||
this.buffer.endsWith("</fu") ||
this.buffer.endsWith("</fun") ||
this.buffer.endsWith("</func") ||
this.buffer.endsWith("</funct") ||
this.buffer.endsWith("</functi") ||
this.buffer.endsWith("</functio") ||
this.buffer.endsWith("</function") ||
this.buffer.endsWith("</function>")
) {
// Wait for more data
return { events, completed: false }
}
// No closing tag found, keep waiting
return { events, completed: false }
}
/**
* Parse parameter tags from content.
* Format: <parameter=name>value</parameter>
*/
private parseParameters(content: string): void {
if (!this.currentToolCall) {
return
}
// Match all parameter tags
const paramRegex = /<parameter=(\w+)>([\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("</function>")) {
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,
}
}
}

View file

@ -0,0 +1,258 @@
import { XmlToolCallParser } from "../XmlToolCallParser"
describe("XmlToolCallParser", () => {
describe("containsXmlToolCall", () => {
it("should detect complete function tags", () => {
expect(XmlToolCallParser.containsXmlToolCall("<function=read_file>")).toBe(true)
expect(XmlToolCallParser.containsXmlToolCall("<function=attempt_completion>")).toBe(true)
expect(XmlToolCallParser.containsXmlToolCall("some text <function=read_file> more text")).toBe(true)
})
it("should detect partial function tags", () => {
expect(XmlToolCallParser.containsXmlToolCall("<function=")).toBe(true)
expect(XmlToolCallParser.containsXmlToolCall("<function")).toBe(true)
expect(XmlToolCallParser.containsXmlToolCall("<")).toBe(true)
})
it("should return false for non-tool-call content", () => {
expect(XmlToolCallParser.containsXmlToolCall("regular text")).toBe(false)
expect(XmlToolCallParser.containsXmlToolCall("some <html> tag")).toBe(false)
expect(XmlToolCallParser.containsXmlToolCall("let x = 5 < 10")).toBe(false)
})
})
describe("parseComplete", () => {
it("should parse a simple tool call", () => {
const text = `<function=read_file>
<parameter=path>src/main.ts</parameter>
</function>`
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 = `<function=edit_file>
<parameter=path>src/app.ts</parameter>
<parameter=old_string>const x = 1</parameter>
<parameter=new_string>const x = 2</parameter>
</function>`
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:
<function=read_file>
<parameter=path>test.txt</parameter>
</function>`
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 = `<function=attempt_completion>
<parameter=result>Task completed successfully. I have analyzed the code and found no issues.</parameter>
</function>`
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 = `<function=write_to_file>
<parameter=path>test.ts</parameter>
<parameter=content>function hello() {
console.log("Hello, World!");
}
</parameter>
</function>`
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("<function=")
expect(result1.events).toHaveLength(0)
expect(result1.isPartialToolCall).toBe(true)
const result2 = parser.processChunk("read_file>")
expect(result2.events.some((e) => e.type === "tool_call_start")).toBe(true)
const result3 = parser.processChunk("<parameter=path>test.ts</para")
expect(result3.events).toHaveLength(0) // Still waiting for parameter end
const result4 = parser.processChunk("meter></function>")
// 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 <function=read_file>")
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("<function=read_file>")
parser.processChunk("<parameter=path>test.ts</parameter>")
// 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("<function=read_file>")
expect(parser.hasPendingContent()).toBe(true)
parser.reset()
expect(parser.hasPendingContent()).toBe(false)
})
})
describe("edge cases", () => {
it("should handle empty parameter values", () => {
const text = `<function=read_file>
<parameter=path></parameter>
</function>`
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 = `<function=execute_command>
<parameter=command>echo "Hello <World>"</parameter>
</function>`
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 <World>"')
})
it("should generate unique tool call IDs", () => {
const result1 = XmlToolCallParser.parseComplete(
"<function=read_file><parameter=path>a.ts</parameter></function>",
)
const result2 = XmlToolCallParser.parseComplete(
"<function=read_file><parameter=path>b.ts</parameter></function>",
)
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.
<function=attempt_completion>
<parameter=result>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.</parameter>
</function>`
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 = `<function=list_files>
</function>`
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)
})
})
})

View file

@ -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<TaskEvents> implements TaskLike {
// Native tool call streaming state (track which index each tool is at)
private streamingToolCallIndices: Map<string, number> = 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<TaskEvents> 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<TaskEvents> 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 <function=TOOL_NAME><parameter=PARAM>VALUE</parameter></function>
// 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<TaskEvents> 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)