mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add XML tool call fallback for OpenAI-compatible providers
Some API providers (like kie.ai with Gemini 3 Pro) do not fully support native function calling and instead output XML-formatted tool calls in text responses. This change adds support for parsing these XML tool calls and executing them as a fallback. Changes: - Add XmlToolCallParser to parse XML tool calls from text content - Modify presentAssistantMessage to detect and parse XML tool calls instead of rejecting them with an error - Add comprehensive tests for the XmlToolCallParser Fixes #11011
This commit is contained in:
parent
b9cf163b87
commit
f39fad586e
3 changed files with 986 additions and 10 deletions
505
src/core/assistant-message/XmlToolCallParser.ts
Normal file
505
src/core/assistant-message/XmlToolCallParser.ts
Normal file
|
|
@ -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:
|
||||
* ```
|
||||
* <read_file>
|
||||
* <path>src/file.ts</path>
|
||||
* </read_file>
|
||||
* ```
|
||||
*/
|
||||
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: <tool_name>...</tool_name>
|
||||
// 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<string, string> {
|
||||
const params = new Map<string, string>()
|
||||
|
||||
// Match parameter tags: <param_name>value</param_name>
|
||||
// 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*<!\[CDATA\[([\s\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<string, string>, 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<Record<ToolParamName, string>> = {}
|
||||
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<string, string>): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
452
src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts
Normal file
452
src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { XmlToolCallParser } from "../XmlToolCallParser"
|
||||
|
||||
describe("XmlToolCallParser", () => {
|
||||
describe("containsXmlToolMarkup", () => {
|
||||
it("should detect read_file tool markup", () => {
|
||||
const text = `<read_file>
|
||||
<path>src/file.ts</path>
|
||||
</read_file>`
|
||||
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect execute_command tool markup", () => {
|
||||
const text = `<execute_command>
|
||||
<command>npm test</command>
|
||||
</execute_command>`
|
||||
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect write_to_file tool markup", () => {
|
||||
const text = `<write_to_file>
|
||||
<path>test.ts</path>
|
||||
<content>console.log('hello')</content>
|
||||
</write_to_file>`
|
||||
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not detect tool markup in code blocks", () => {
|
||||
const text = "```\n<read_file><path>src/file.ts</path></read_file>\n```"
|
||||
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false)
|
||||
})
|
||||
|
||||
it("should not detect tool markup in inline code", () => {
|
||||
const text = "Use `<read_file><path>src/file.ts</path></read_file>` to read files"
|
||||
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(false)
|
||||
})
|
||||
|
||||
it("should not detect non-tool XML tags", () => {
|
||||
const text = "<div>Hello</div><span>World</span>"
|
||||
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 = "<READ_FILE><path>src/file.ts</path></READ_FILE>"
|
||||
expect(XmlToolCallParser.containsXmlToolMarkup(text)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseXmlToolCalls", () => {
|
||||
describe("read_file tool", () => {
|
||||
it("should parse simple read_file tool call", () => {
|
||||
const text = `<read_file>
|
||||
<path>src/file.ts</path>
|
||||
</read_file>`
|
||||
|
||||
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 = `<read_file>
|
||||
<files>[{"path": "src/file.ts"}]</files>
|
||||
</read_file>`
|
||||
|
||||
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 = `<execute_command>
|
||||
<command>npm test</command>
|
||||
</execute_command>`
|
||||
|
||||
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 = `<execute_command>
|
||||
<command>npm test</command>
|
||||
<cwd>./packages/core</cwd>
|
||||
</execute_command>`
|
||||
|
||||
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 = `<write_to_file>
|
||||
<path>test.ts</path>
|
||||
<content>console.log('hello')</content>
|
||||
</write_to_file>`
|
||||
|
||||
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 = `<write_to_file>
|
||||
<path>test.ts</path>
|
||||
<content>function hello() {
|
||||
console.log('hello')
|
||||
}
|
||||
|
||||
export { hello }</content>
|
||||
</write_to_file>`
|
||||
|
||||
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 = `<apply_diff>
|
||||
<path>src/file.ts</path>
|
||||
<diff>--- a/src/file.ts
|
||||
+++ b/src/file.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
-const x = 1
|
||||
+const x = 2</diff>
|
||||
</apply_diff>`
|
||||
|
||||
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 = `<list_files>
|
||||
<path>src/</path>
|
||||
<recursive>true</recursive>
|
||||
</list_files>`
|
||||
|
||||
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 = `<list_files>
|
||||
<path>./</path>
|
||||
<recursive>false</recursive>
|
||||
</list_files>`
|
||||
|
||||
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 = `<search_files>
|
||||
<path>src/</path>
|
||||
<regex>TODO:</regex>
|
||||
<file_pattern>*.ts</file_pattern>
|
||||
</search_files>`
|
||||
|
||||
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 = `<attempt_completion>
|
||||
<result>The task has been completed successfully.</result>
|
||||
</attempt_completion>`
|
||||
|
||||
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 = `<ask_followup_question>
|
||||
<question>What file would you like me to read?</question>
|
||||
<follow_up>[{"text": "src/file.ts"}, {"text": "package.json"}]</follow_up>
|
||||
</ask_followup_question>`
|
||||
|
||||
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 = `<switch_mode>
|
||||
<mode_slug>code</mode_slug>
|
||||
<reason>Need to implement the feature</reason>
|
||||
</switch_mode>`
|
||||
|
||||
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.
|
||||
|
||||
<read_file>
|
||||
<path>src/file.ts</path>
|
||||
</read_file>
|
||||
|
||||
<execute_command>
|
||||
<command>npm test</command>
|
||||
</execute_command>`
|
||||
|
||||
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:
|
||||
|
||||
<read_file>
|
||||
<path>src/file.ts</path>
|
||||
</read_file>
|
||||
|
||||
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 = `<write_to_file>
|
||||
<path>test.ts</path>
|
||||
<content><![CDATA[const x = 1; // This <should> work]]></content>
|
||||
</write_to_file>`
|
||||
|
||||
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 <should> 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 = `<read_file><path>file1.ts</path></read_file>
|
||||
<read_file><path>file2.ts</path></read_file>`
|
||||
|
||||
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 = `<attempt_completion>
|
||||
<result></result>
|
||||
</attempt_completion>`
|
||||
|
||||
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 = `<read_file>
|
||||
<path> src/file.ts </path>
|
||||
</read_file>`
|
||||
|
||||
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 = `<edit_file>
|
||||
<file_path>src/file.ts</file_path>
|
||||
<old_string>const x = 1</old_string>
|
||||
<new_string>const x = 2</new_string>
|
||||
</edit_file>`
|
||||
|
||||
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 = `<use_mcp_tool>
|
||||
<server_name>my-server</server_name>
|
||||
<tool_name>my-tool</tool_name>
|
||||
<arguments>{"key": "value"}</arguments>
|
||||
</use_mcp_tool>`
|
||||
|
||||
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 = `<browser_action>
|
||||
<action>launch</action>
|
||||
<url>https://example.com</url>
|
||||
</browser_action>`
|
||||
|
||||
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 = `<new_task>
|
||||
<mode>architect</mode>
|
||||
<message>Design the new feature</message>
|
||||
</new_task>`
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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(/<thinking>\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. <read_file>...</read_file>) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue