mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
934f34ea87
commit
c3e2ca41a6
3 changed files with 672 additions and 14 deletions
373
src/core/assistant-message/XmlToolCallParser.ts
Normal file
373
src/core/assistant-message/XmlToolCallParser.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts
Normal file
258
src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue