diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index fef7d811a4..acaead984e 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -160,6 +160,7 @@ const openAiSchema = baseProviderSettingsSchema.extend({ openAiStreamingEnabled: z.boolean().optional(), openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration. openAiHeaders: z.record(z.string(), z.string()).optional(), + openAiXmlAutoRepair: z.boolean().optional(), // Auto-repair broken XML tool calls from certain providers }) const ollamaSchema = baseProviderSettingsSchema.extend({ diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index f196b5f309..5fefab572b 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -6,6 +6,7 @@ import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" +import { repairBrokenXml } from "../utils/xml-repair" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { DEFAULT_HEADERS } from "./constants" @@ -89,9 +90,12 @@ export abstract class BaseOpenAiCompatibleProvider const delta = chunk.choices[0]?.delta if (delta?.content) { + // Apply XML repair if enabled for OpenAI-compatible providers + const content = this.options.openAiXmlAutoRepair ? repairBrokenXml(delta.content) : delta.content + yield { type: "text", - text: delta.content, + text: content, } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 939816480a..d641e836a7 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -13,6 +13,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { XmlMatcher } from "../../utils/xml-matcher" +import { repairBrokenXml } from "../utils/xml-repair" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" @@ -187,7 +188,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const delta = chunk.choices[0]?.delta ?? {} if (delta.content) { - for (const chunk of matcher.update(delta.content)) { + // Apply XML repair if enabled + const content = this.options.openAiXmlAutoRepair ? repairBrokenXml(delta.content) : delta.content + + for (const chunk of matcher.update(content)) { yield chunk } } @@ -362,9 +366,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl for await (const chunk of stream) { const delta = chunk.choices[0]?.delta if (delta?.content) { + // Apply XML repair if enabled + const content = this.options.openAiXmlAutoRepair ? repairBrokenXml(delta.content) : delta.content + yield { type: "text", - text: delta.content, + text: content, } } diff --git a/src/api/utils/__tests__/xml-repair.spec.ts b/src/api/utils/__tests__/xml-repair.spec.ts new file mode 100644 index 0000000000..4e69d4a11c --- /dev/null +++ b/src/api/utils/__tests__/xml-repair.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest" +import { repairBrokenXml, hasBrokenXmlPattern } from "../xml-repair" + +describe("xml-repair", () => { + describe("hasBrokenXmlPattern", () => { + it("should detect broken tool opening tags", () => { + const brokenXml = "read_file>\n\n\n" + expect(hasBrokenXmlPattern(brokenXml)).toBe(true) + }) + + it("should detect broken parameter opening tags", () => { + const brokenXml = "\nargs>\n\n" + expect(hasBrokenXmlPattern(brokenXml)).toBe(true) + }) + + it("should detect broken closing tags", () => { + const brokenXml = "\n\n/args>\n/read_file>" + expect(hasBrokenXmlPattern(brokenXml)).toBe(true) + }) + + it("should not detect valid XML as broken", () => { + const validXml = "\n\n\n" + expect(hasBrokenXmlPattern(validXml)).toBe(false) + }) + }) + + describe("repairBrokenXml", () => { + it("should repair missing opening brackets for tool tags", () => { + const brokenXml = "read_file>\n\n\n/read_file>" + const expected = "\n\n\n" + expect(repairBrokenXml(brokenXml)).toBe(expected) + }) + + it("should repair missing opening brackets for parameter tags", () => { + const brokenXml = "\nargs>\n/args>\n" + const expected = "\n\n\n" + expect(repairBrokenXml(brokenXml)).toBe(expected) + }) + + it("should handle the example from the issue", () => { + // This is the exact example from the issue + const brokenXml = "read_file>\nargs>\n\nmain.gopath>\n\nargs>\nread_file>" + const expected = "\n\n\nmain.go\n\n\n" + expect(repairBrokenXml(brokenXml)).toBe(expected) + }) + + it("should not modify valid XML", () => { + const validXml = "\ntest.txt\n" + expect(repairBrokenXml(validXml)).toBe(validXml) + }) + + it("should handle execute_command with broken tags", () => { + const brokenXml = "execute_command>\ntest\n/execute_command>" + const expected = "\ntest\n" + expect(repairBrokenXml(brokenXml)).toBe(expected) + }) + + it("should handle search_files with broken tags", () => { + const brokenXml = "search_files>\nsrc\nregex>pattern\n/search_files>" + const expected = "\nsrc\npattern\n" + expect(repairBrokenXml(brokenXml)).toBe(expected) + }) + }) +}) diff --git a/src/api/utils/xml-repair.ts b/src/api/utils/xml-repair.ts new file mode 100644 index 0000000000..48fd96a985 --- /dev/null +++ b/src/api/utils/xml-repair.ts @@ -0,0 +1,261 @@ +/** + * Utility functions for repairing broken XML tool calls from LLM providers + * that don't properly format XML responses. + */ + +import { toolNames, type ToolName } from "@roo-code/types" +import { toolParamNames, type ToolParamName } from "../../shared/tools" + +/** + * Attempts to repair broken XML tool calls by adding missing opening brackets + * and fixing common formatting issues. + * + * @param brokenXml - The potentially broken XML string + * @returns The repaired XML string, or the original if no repairs were needed + * + * @example + * // Input: "read_file>\nargs>\n\nmain.gopath>\n\nargs>\nread_file>" + * // Output: "\n\n\nmain.go\n\n\n" + */ +export function repairBrokenXml(brokenXml: string): string { + // Don't check for valid structure - we need to repair even partially valid XML + let repairedXml = brokenXml + + // Split into lines for easier processing + const lines = repairedXml.split("\n") + const repairedLines: string[] = [] + + // Create a set of all valid tag names for quick lookup + const allTagNames = new Set([...toolNames, ...toolParamNames]) + + // Track open tags to determine if we need opening or closing tags + const openTags: string[] = [] + + for (let line of lines) { + let repairedLine = line + const trimmedLine = line.trim() + + // Skip empty lines + if (!trimmedLine) { + repairedLines.push(repairedLine) + continue + } + + // Get the indentation from the original line + const indent = line.match(/^(\s*)/)?.[1] || "" + + // Handle lines that already start with < and end with > + if (trimmedLine.startsWith("<") && trimmedLine.endsWith(">")) { + // Check for double brackets like < or < + if (trimmedLine.match(/^<<|>>$/)) { + repairedLine = repairedLine.replace(/<<\//g, ">/g, ">") + } + + // Check for special case: main.gopath> should become main.go + // This happens when content is merged with the tag name (missing (.+)${tagName}>$`) + const mergedMatch = trimmedLine.match(mergedPattern) + if (mergedMatch) { + // Remove the tag name from the content + const content = mergedMatch[1] + const cleanContent = content.endsWith(tagName) + ? content.substring(0, content.length - tagName.length) + : content + repairedLine = `${indent}<${tagName}>${cleanContent}` + break + } + } + } + + // Track open/closed tags + if (trimmedLine.startsWith("]+)>$/)?.[1] + if (tagName) { + // Remove from open tags if it matches + const lastIndex = openTags.lastIndexOf(tagName) + if (lastIndex >= 0) { + openTags.splice(lastIndex, 1) + } + } + } else { + const tagName = trimmedLine.match(/^<([^/>]+)>$/)?.[1] + if (tagName && allTagNames.has(tagName)) { + openTags.push(tagName) + } + } + + repairedLines.push(repairedLine) + continue + } + + // Handle lines that don't start with < (missing opening bracket) + let handled = false + + // Check for patterns like "regex>pattern" + // This needs to be handled before other patterns + for (const tagName of allTagNames) { + const contentPattern = new RegExp(`^${tagName}>(.+)$`) + const contentMatch = trimmedLine.match(contentPattern) + if (contentMatch) { + repairedLine = `${indent}<${tagName}>${contentMatch[1]}` + handled = true + break + } + } + + if (!handled) { + // Check each known tag name + for (const tagName of allTagNames) { + // Pattern 1: "/tagName>" at start of line (missing opening bracket for closing tag) + if (trimmedLine === `/${tagName}>` || trimmedLine.startsWith(`/${tagName}>`)) { + repairedLine = `${indent}` + // Remove from open tags if it matches + const lastIndex = openTags.lastIndexOf(tagName) + if (lastIndex >= 0) { + openTags.splice(lastIndex, 1) + } + handled = true + break + } + + // Pattern 2: "tagName>" at start of line + if (trimmedLine === `${tagName}>`) { + // Check if we have this tag open - if so, it's likely a closing tag + if (openTags.includes(tagName)) { + repairedLine = `${indent}` + // Remove from open tags + const lastIndex = openTags.lastIndexOf(tagName) + if (lastIndex >= 0) { + openTags.splice(lastIndex, 1) + } + } else { + // It's an opening tag + repairedLine = `${indent}<${tagName}>` + openTags.push(tagName) + } + handled = true + break + } + + // Pattern 3: "tagName>" with content after it + if (trimmedLine.startsWith(`${tagName}>`)) { + const restOfLine = trimmedLine.substring(tagName.length + 1) + // Check if this is something like "main.gopath>" where content is merged with tag + if (restOfLine.endsWith(`${tagName}>`)) { + // Remove the tag name from the end of content + const content = restOfLine.substring(0, restOfLine.length - tagName.length - 1) + // Remove the tag name if it appears at the end of content (like "main.gopath" -> "main.go") + const cleanContent = content.endsWith(tagName) + ? content.substring(0, content.length - tagName.length) + : content + repairedLine = `${indent}<${tagName}>${cleanContent}` + } else { + // Just missing the opening bracket + repairedLine = `${indent}<${tagName}>${restOfLine}` + openTags.push(tagName) + } + handled = true + break + } + } + } + + // Handle special case: content ending with "tagName>" like "main.gopath>" + if (!handled) { + for (const tagName of allTagNames) { + // Check if line ends with "tagName>" and doesn't start with a tag + if (trimmedLine.endsWith(`${tagName}>`) && !trimmedLine.startsWith("<")) { + const beforeTag = trimmedLine.substring(0, trimmedLine.length - tagName.length - 1) + if (beforeTag) { + // Check if the content ends with the tag name (like "main.gopath") + if (beforeTag.endsWith(tagName)) { + const cleanContent = beforeTag.substring(0, beforeTag.length - tagName.length) + repairedLine = `${indent}${cleanContent}` + } else { + // Content doesn't end with tag name, just add closing tag + repairedLine = `${indent}${beforeTag}` + } + // Remove from open tags if it matches + const lastIndex = openTags.lastIndexOf(tagName) + if (lastIndex >= 0) { + openTags.splice(lastIndex, 1) + } + handled = true + break + } + } + } + } + + repairedLines.push(repairedLine) + } + + return repairedLines.join("\n") +} + +/** + * Checks if the XML has a valid structure with proper opening and closing tags + */ +function hasValidXmlStructure(xml: string): boolean { + // Check if we have at least one valid tool opening tag + const hasValidToolTag = toolNames.some( + (toolName) => xml.includes(`<${toolName}>`) && xml.includes(``), + ) + + return hasValidToolTag +} + +/** + * Detects if a string contains broken XML patterns that need repair + * + * @param text - The text to check for broken XML + * @returns true if broken XML patterns are detected + */ +export function hasBrokenXmlPattern(text: string): boolean { + const lines = text.split("\n") + + for (const line of lines) { + const trimmedLine = line.trim() + + // Check for tool names without opening brackets + for (const toolName of toolNames) { + // Check if line starts with toolName> (missing opening bracket) + if (trimmedLine.startsWith(`${toolName}>`) || trimmedLine.startsWith(`/${toolName}>`)) { + return true + } + // Check if line is just toolName> (likely a closing tag) + if (trimmedLine === `${toolName}>`) { + return true + } + } + + // Check for parameter names without opening brackets + for (const paramName of toolParamNames) { + // Check if line starts with paramName> (missing opening bracket) + if (trimmedLine.startsWith(`${paramName}>`) || trimmedLine.startsWith(`/${paramName}>`)) { + return true + } + // Check if line is just paramName> (likely a closing tag) + if (trimmedLine === `${paramName}>`) { + return true + } + } + } + + return false +} + +/** + * Configuration for XML auto-repair behavior + */ +export interface XmlAutoRepairConfig { + /** Whether to enable automatic XML repair */ + enabled: boolean + /** Whether to use a small model for repair (future enhancement) */ + useSmallModel?: boolean + /** The model to use for repair if useSmallModel is true */ + repairModelId?: string +} diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index ee462296b5..a0db3888e4 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -167,6 +167,16 @@ export const OpenAICompatible = ({ onChange={handleInputChange("openAiStreamingEnabled", noTransform)}> {t("settings:modelInfo.enableStreaming")} +
+ + {t("settings:providers.xmlAutoRepair")} + +
+ {t("settings:providers.xmlAutoRepairDescription")} +
+