diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 7434b4de45d..be64689a2da 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -56,7 +56,13 @@ vi.mock("vscode", () => { import * as vscode from "vscode" import { openAiModelInfoSaneDefaults, vscodeLlmModels } from "@roo-code/types" -import { VsCodeLmHandler } from "../vscode-lm" +import { + VsCodeLmHandler, + extractLeakedToolCalls, + trailingPartialToolMarkerLength, + middleOutTruncate, + truncateToolResultsToFitWindow, +} from "../vscode-lm" import type { ApiHandlerOptions } from "../../../shared/api" import type { Anthropic } from "@anthropic-ai/sdk" @@ -593,3 +599,193 @@ describe("VsCodeLmHandler", () => { }) }) }) + +describe("leaked tool-call recovery", () => { + // Builders keep the XML fixtures readable while avoiding giant inline string literals. + const invoke = (name: string, body: string) => `${body}` + const param = (name: string, value: string) => `${value}` + + describe("extractLeakedToolCalls", () => { + it("recovers a known-tool block and strips it from the leftover text", () => { + const text = `Working on it.\n${invoke("update_todo_list", param("todos", "[x] one\n[ ] two"))}` + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"])) + + expect(calls).toEqual([{ name: "update_todo_list", input: { todos: "[x] one\n[ ] two" } }]) + expect(leftoverText).toBe("Working on it.\n") + }) + + it("recovers the real-world 'court'-prefixed, unwrapped leak", () => { + // Copilot/Claude sometimes streams the call as text with a stray leading token and + // no wrapper — the exact shape observed in the field. + const text = `court\n${invoke("update_todo_list", param("todos", "[x] done"))}` + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"])) + + expect(calls).toEqual([{ name: "update_todo_list", input: { todos: "[x] done" } }]) + expect(leftoverText).toBe("court\n") + }) + + it("recovers multiple params and strips function-call wrapper tags", () => { + const body = param("mode", "code") + param("message", "go") + const text = `${invoke("new_task", body)}` + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["new_task"])) + + expect(calls).toEqual([{ name: "new_task", input: { mode: "code", message: "go" } }]) + expect(leftoverText).toBe("") + }) + + it("passes through invoke blocks for tools that were not offered", () => { + const text = invoke("some_other_tool", param("x", "1")) + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"])) + + expect(calls).toEqual([]) + expect(leftoverText).toBe(text) + }) + + it("returns no calls for ordinary text", () => { + const { calls, leftoverText } = extractLeakedToolCalls("just a normal reply", new Set(["update_todo_list"])) + + expect(calls).toEqual([]) + expect(leftoverText).toBe("just a normal reply") + }) + }) + + describe("trailingPartialToolMarkerLength", () => { + it("holds back a split marker prefix at the end of a chunk", () => { + expect(trailingPartialToolMarkerLength("some text { + expect(trailingPartialToolMarkerLength("hello world")).toBe(0) + expect(trailingPartialToolMarkerLength("a < b")).toBe(0) + expect(trailingPartialToolMarkerLength("text ")).toBe(0) + }) + }) +}) + +describe("context-window tool_result truncation", () => { + describe("middleOutTruncate", () => { + it("returns text unchanged when within the limit", () => { + expect(middleOutTruncate("hello world", 100)).toBe("hello world") + }) + + it("keeps the head and tail and inserts a truncation marker", () => { + const text = "A".repeat(500) + "B".repeat(500) + const result = middleOutTruncate(text, 200) + + expect(result.length).toBeLessThanOrEqual(200) + expect(result).toContain("characters truncated to fit the model context window") + expect(result.startsWith("A")).toBe(true) + expect(result.endsWith("B")).toBe(true) + }) + + it("returns an empty string for a non-positive limit", () => { + expect(middleOutTruncate("anything", 0)).toBe("") + }) + }) + + describe("truncateToolResultsToFitWindow", () => { + const toolUseMessage = (id: string): Anthropic.Messages.MessageParam => ({ + role: "assistant", + content: [ + { type: "text", text: "Calling a tool." }, + { type: "tool_use", id, name: "some_tool", input: { a: 1 } }, + ], + }) + + const toolResultMessage = (id: string, content: string): Anthropic.Messages.MessageParam => ({ + role: "user", + content: [ + { type: "tool_result", tool_use_id: id, content }, + { type: "text", text: "env" }, + ], + }) + + const findBlock = (message: Anthropic.Messages.MessageParam, type: string) => + (message.content as unknown as Array<{ type: string; [key: string]: unknown }>).find( + (block) => block.type === type, + )! + + it("is a no-op when the conversation already fits the budget", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "small result"), + ] + const before = JSON.parse(JSON.stringify(messages)) + + truncateToolResultsToFitWindow(messages, 100_000) + + expect(messages).toEqual(before) + }) + + it("shrinks an oversized tool_result so the conversation fits the budget", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "X".repeat(50_000)), + ] + + truncateToolResultsToFitWindow(messages, 10_000) + + const toolResult = findBlock(messages[1], "tool_result") + expect(toolResult.tool_use_id).toBe("t1") // pairing preserved + expect(String(toolResult.content).length).toBeLessThanOrEqual(10_000) + expect(String(toolResult.content)).toContain("characters truncated") + }) + + it("truncates the largest tool_result first and leaves small ones intact", () => { + const small = "small but real result" + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "B".repeat(40_000)), + toolUseMessage("t2"), + toolResultMessage("t2", small), + ] + + truncateToolResultsToFitWindow(messages, 12_000) + + expect(String(findBlock(messages[1], "tool_result").content)).toContain("characters truncated") + expect(findBlock(messages[3], "tool_result").content).toBe(small) // untouched + }) + + it("never truncates tool_use blocks, assistant text, or environment details", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "X".repeat(50_000)), + ] + + truncateToolResultsToFitWindow(messages, 8_000) + + expect(findBlock(messages[0], "text").text).toBe("Calling a tool.") + expect(findBlock(messages[0], "tool_use")).toMatchObject({ id: "t1", name: "some_tool" }) + expect(findBlock(messages[1], "text").text).toBe("env") + }) + + it("handles array-form tool_result content and keeps it valid", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: [{ type: "text", text: "Y".repeat(40_000) }], + }, + ], + }, + ] + + truncateToolResultsToFitWindow(messages, 8_000) + + const toolResult = findBlock(messages[1], "tool_result") + expect(toolResult.tool_use_id).toBe("t1") + expect(Array.isArray(toolResult.content)).toBe(true) + const parts = toolResult.content as Array<{ type: string; text?: string }> + expect(parts[0].type).toBe("text") + expect(String(parts[0].text)).toContain("characters truncated") + }) + }) +}) diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index d730658b446..7500c2b13ee 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -33,6 +33,266 @@ function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode })) } +/** + * Recovery for leaked tool calls + * ------------------------------ + * Some VS Code LM backends — notably GitHub Copilot serving Anthropic Claude models — + * intermittently stream a tool call as PLAIN TEXT using Anthropic's internal function-call + * XML instead of emitting a structured `LanguageModelToolCallPart`. The leaked text looks + * like this (sometimes without the outer `` wrapper and often prefixed by a + * stray decode-artifact token, which is what makes the upstream parser miss it): + * + * + * [x] ... + * + * + * When this happens the assistant turn contains no tool_use block, so Roo reports + * "no tools used" and the task stalls in a retry loop. The helpers below detect the leaked + * markup mid-stream and replay it as a real tool call. Recovery is deliberately + * conservative: only `` blocks whose name matches a tool we actually offered this + * turn are treated as calls; everything else is passed through unchanged as text. + */ +const LEAKED_TOOL_CALL_START = /<(?:antml:)?(?:function_calls|invoke)\b/i +const LEAKED_INVOKE_BLOCK = /<(?:antml:)?invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?invoke\s*>/gi +const LEAKED_INVOKE_PARAM = /<(?:antml:)?parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?parameter\s*>/gi + +/** + * Returns the length of a trailing ` { + const input: Record = {} + LEAKED_INVOKE_PARAM.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = LEAKED_INVOKE_PARAM.exec(body)) !== null) { + input[match[1]] = match[2].trim() + } + return input +} + +/** + * Extracts complete leaked `` tool-call blocks from `text`. Only blocks whose name + * is present in `validToolNames` are returned as calls; all other text (including `` + * blocks for unknown names) is returned as `leftoverText` so legitimate prose is preserved. + * Bare `` wrapper tags are stripped from the leftover text. + */ +export function extractLeakedToolCalls( + text: string, + validToolNames: ReadonlySet, +): { calls: Array<{ name: string; input: Record }>; leftoverText: string } { + const calls: Array<{ name: string; input: Record }> = [] + let leftover = "" + let lastIndex = 0 + + LEAKED_INVOKE_BLOCK.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = LEAKED_INVOKE_BLOCK.exec(text)) !== null) { + leftover += text.slice(lastIndex, match.index) + const name = match[1] + if (validToolNames.has(name)) { + calls.push({ name, input: parseLeakedInvokeParams(match[2]) }) + } else { + // Not one of our tools — keep the block as literal text. + leftover += match[0] + } + lastIndex = match.index + match[0].length + } + leftover += text.slice(lastIndex) + + // Remove bare function-call wrapper tags left behind (cosmetic; also avoids re-teaching + // the model this format when the turn is later sent back as history). + leftover = leftover.replace(/<\/?(?:antml:)?function_calls\s*>/gi, "") + + return { calls, leftoverText: leftover } +} + +/** + * Context-window safety for Copilot's backend + * ------------------------------------------- + * When Roo sends messages through the VS Code LM API, GitHub Copilot's backend enforces its own + * context window. For third-party `sendRequest` callers it trims an over-window request in a way + * that is NOT tool-pair-aware: it can drop the assistant message holding a `tool_use` while + * keeping the matching `tool_result`, after which Anthropic rejects the request with + * "messages.0.content.0: unexpected tool_use_id ... Each tool_result block must have a + * corresponding tool_use block in the previous message." A single oversized MCP tool result + * (e.g. a large build log or file dump) in the most-recent turn is enough to push a freshly + * condensed request over the window — and condensing cannot shrink the newest turn. + * + * To keep trimming on OUR side — where tool_use/tool_result pairing is preserved — we shrink + * oversized `tool_result` payloads before sending so the request fits the window. Only + * `tool_result` text is truncated (never `tool_use`, assistant text, summaries, or environment + * details), and only when the request would otherwise exceed the budget, so normal-sized + * requests are left untouched. + */ + +/** + * Conservative characters-per-token ratio used to turn a token window into a character budget. + * The token-dense JSON, logs, and code that dominate oversized tool results tokenize to fewer + * characters per token than prose, so we intentionally under-count (3, not the ~4 typical of + * English) to keep the resulting budget on the safe side of the enforced window. + */ +const VSCODE_LM_BUDGET_CHARS_PER_TOKEN = 3 + +/** + * Fraction of the context window the *entire* input (system prompt + tool schemas + conversation) + * is allowed to occupy. The remaining headroom absorbs char/token estimation variance and any + * output/overhead the backend reserves. + */ +const VSCODE_LM_INPUT_BUDGET_FRACTION = 0.8 + +/** A tool_result is never shrunk below this many characters, so a truncated result stays useful. */ +const MIN_TOOL_RESULT_CHARS = 2000 + +function readToolResultText(block: Anthropic.Messages.ContentBlockParam): string | undefined { + if (!block || (block as { type?: string }).type !== "tool_result") { + return undefined + } + const content = (block as Anthropic.Messages.ToolResultBlockParam).content + if (typeof content === "string") { + return content + } + if (Array.isArray(content)) { + return content + .filter((part): part is Anthropic.Messages.TextBlockParam => (part as { type?: string })?.type === "text") + .map((part) => part.text ?? "") + .join("") + } + return undefined +} + +function writeToolResultText(block: Anthropic.Messages.ContentBlockParam, text: string): void { + const toolResult = block as Anthropic.Messages.ToolResultBlockParam + const content = toolResult.content + if (Array.isArray(content)) { + // Preserve any non-text parts (e.g. images) and collapse the text into one truncated part. + const nonText = content.filter((part) => (part as { type?: string })?.type !== "text") + toolResult.content = [{ type: "text", text }, ...nonText] as typeof content + return + } + toolResult.content = text +} + +/** + * Middle-out truncate `text` to at most `maxChars`, keeping the head and tail and replacing the + * middle with a marker noting how many characters were removed. Head/tail are preserved because + * logs and file dumps carry the most signal at their start (structure) and end (recent output). + */ +export function middleOutTruncate(text: string, maxChars: number): string { + if (maxChars <= 0) { + return "" + } + if (text.length <= maxChars) { + return text + } + + const buildMarker = (removed: number) => + `\n\n[... ${removed.toLocaleString("en-US")} characters truncated to fit the model context window ...]\n\n` + + // Reserve room for the marker, sized against the original length so the result never grows. + const reservedMarkerLength = buildMarker(text.length).length + const keep = Math.max(0, maxChars - reservedMarkerLength) + const headLength = Math.ceil(keep / 2) + const tailLength = keep - headLength + let head = text.slice(0, headLength) + // Don't end the head on a lone high surrogate — its low half is in the removed middle, and a lone + // surrogate cannot be encoded as UTF-8 (the backend 400s the whole request). Drop the split half. + if (head.length > 0 && (head.charCodeAt(head.length - 1) & 0xfc00) === 0xd800) { + head = head.slice(0, -1) + } + let tail = tailLength > 0 ? text.slice(text.length - tailLength) : "" + // Likewise, don't start the tail on a lone low surrogate (its high half is in the removed middle). + if (tail.length > 0 && (tail.charCodeAt(0) & 0xfc00) === 0xdc00) { + tail = tail.slice(1) + } + const removed = text.length - head.length - tail.length + return `${head}${buildMarker(removed)}${tail}` +} + +function estimateContentChars(content: Anthropic.Messages.MessageParam["content"]): number { + if (typeof content === "string") { + return content.length + } + if (!Array.isArray(content)) { + return 0 + } + let total = 0 + for (const block of content) { + const type = (block as { type?: string })?.type + if (type === "text") { + total += (block as Anthropic.Messages.TextBlockParam).text?.length ?? 0 + } else if (type === "tool_result") { + total += readToolResultText(block)?.length ?? 0 + } else if (type === "tool_use") { + total += JSON.stringify((block as Anthropic.Messages.ToolUseBlockParam).input ?? {}).length + } else if (type === "image") { + total += 8 // "[IMAGE]" placeholder — VS Code LM drops image data anyway. + } + } + return total +} + +/** + * Shrinks oversized `tool_result` payloads (largest first, middle-out) until the conversation fits + * `budgetChars`. Mutates the tool_result blocks of the supplied messages in place — callers pass a + * cloned array (see `createMessage`) so stored history is never mutated. Returns the same array for + * convenience. A no-op when the conversation already fits. + */ +export function truncateToolResultsToFitWindow( + messages: Anthropic.Messages.MessageParam[], + budgetChars: number, +): Anthropic.Messages.MessageParam[] { + if (!Number.isFinite(budgetChars) || budgetChars <= 0) { + return messages + } + + let total = messages.reduce((sum, message) => sum + estimateContentChars(message.content), 0) + if (total <= budgetChars) { + return messages + } + + // Collect every truncatable tool_result block, largest first. + const toolResultBlocks: Anthropic.Messages.ContentBlockParam[] = [] + for (const message of messages) { + if (!Array.isArray(message.content)) { + continue + } + for (const block of message.content) { + if (readToolResultText(block) !== undefined) { + toolResultBlocks.push(block) + } + } + } + toolResultBlocks.sort((a, b) => (readToolResultText(b)?.length ?? 0) - (readToolResultText(a)?.length ?? 0)) + + for (const block of toolResultBlocks) { + if (total <= budgetChars) { + break + } + const text = readToolResultText(block) + if (text === undefined || text.length <= MIN_TOOL_RESULT_CHARS) { + continue + } + + const overage = total - budgetChars + const target = Math.max(MIN_TOOL_RESULT_CHARS, text.length - overage) + if (target >= text.length) { + continue + } + + const truncated = middleOutTruncate(text, target) + total -= text.length - truncated.length + writeToolResultText(block, truncated) + } + + return messages +} + /** * Handles interaction with VS Code's Language Model API for chat-based operations. * This handler extends BaseProvider to provide VS Code LM specific functionality. @@ -377,6 +637,20 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan content: this.cleanMessageContent(msg.content), })) + // Keep context-window trimming on OUR side. Copilot's backend trims an over-window request + // without preserving tool_use/tool_result pairing, which orphans a tool_result and triggers a + // 400 ("messages.0.content.0: unexpected tool_use_id"). Shrink oversized tool_result payloads + // so the request fits the window before we hand it off. See truncateToolResultsToFitWindow. + const contextWindowTokens = this.getCondenseContextWindow() + if (Number.isFinite(contextWindowTokens) && contextWindowTokens > 0) { + const toolSchemaChars = metadata?.tools ? JSON.stringify(metadata.tools).length : 0 + const messagesBudgetChars = + contextWindowTokens * VSCODE_LM_INPUT_BUDGET_FRACTION * VSCODE_LM_BUDGET_CHARS_PER_TOKEN - + systemPrompt.length - + toolSchemaChars + truncateToolResultsToFitWindow(cleanedMessages, messagesBudgetChars) + } + // Convert Anthropic messages to VS Code LM messages const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ vscode.LanguageModelChatMessage.Assistant(systemPrompt), @@ -392,6 +666,20 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Accumulate the text and count at the end of the stream to reduce token counting overhead. let accumulatedText: string = "" + // Leaked tool-call recovery state (see `extractLeakedToolCalls`). Only enabled when we + // actually offered tools this turn, so it can never misfire on plain conversations. + const providedToolNames = new Set( + (metadata?.tools ?? []) + .filter((tool) => tool.type === "function") + .map((tool) => tool.function.name) + .filter((name) => name.length > 0), + ) + const salvageLeakedToolCalls = providedToolNames.size > 0 + let salvageBuffering = false + let salvageBuffer = "" + let salvageCarry = "" + let salvagedToolCallIndex = 0 + try { // Create the response stream with required options const requestOptions: vscode.LanguageModelChatRequestOptions = { @@ -415,9 +703,40 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } accumulatedText += chunk.value - yield { - type: "text", - text: chunk.value, + + // Fast path: when we didn't offer any tools there is nothing to salvage, so + // stream the text straight through exactly as before. + if (!salvageLeakedToolCalls) { + yield { type: "text", text: chunk.value } + continue + } + + // Once we've seen the start of a leaked tool-call block, buffer the rest of the + // stream so the full markup can be parsed and replayed as a structured call. + if (salvageBuffering) { + salvageBuffer += chunk.value + continue + } + + // Watch for the start of a leaked tool-call block, carrying a small tail across + // chunks so a marker split across chunk boundaries is still detected. + const combined = salvageCarry + chunk.value + const markerMatch = combined.match(LEAKED_TOOL_CALL_START) + if (markerMatch) { + const before = combined.slice(0, markerMatch.index) + if (before) { + yield { type: "text", text: before } + } + salvageBuffering = true + salvageBuffer = combined.slice(markerMatch.index) + salvageCarry = "" + } else { + const carryLength = trailingPartialToolMarkerLength(combined) + const emit = carryLength > 0 ? combined.slice(0, combined.length - carryLength) : combined + salvageCarry = carryLength > 0 ? combined.slice(combined.length - carryLength) : "" + if (emit) { + yield { type: "text", text: emit } + } } } else if (chunk instanceof vscode.LanguageModelToolCallPart) { try { @@ -466,6 +785,37 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } + // Flush any leaked tool-call recovery state accumulated during streaming. + if (salvageLeakedToolCalls) { + // A carried tail that never became a marker is just ordinary text. + if (!salvageBuffering && salvageCarry) { + yield { type: "text", text: salvageCarry } + } + + if (salvageBuffering && salvageBuffer) { + const { calls, leftoverText } = extractLeakedToolCalls(salvageBuffer, providedToolNames) + + // Emit surrounding prose first so recovered tool calls come last, matching the + // ordering of a normal native tool-calling turn. + if (leftoverText) { + yield { type: "text", text: leftoverText } + } + + for (const call of calls) { + console.warn( + "Roo Code : Recovered a tool call the model emitted as text instead of a structured tool call:", + { name: call.name, params: Object.keys(call.input) }, + ) + yield { + type: "tool_call", + id: `vscodelm-salvaged-${Date.now()}-${salvagedToolCallIndex++}`, + name: call.name, + arguments: JSON.stringify(call.input), + } + } + } + } + // Count tokens in the accumulated text after stream completion const totalOutputTokens: number = await this.internalCountTokens(accumulatedText) diff --git a/src/api/transform/__tests__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts index e60860b5491..b3b1e000b65 100644 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ b/src/api/transform/__tests__/vscode-lm-format.spec.ts @@ -3,7 +3,12 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" -import { convertToVsCodeLmMessages, convertToAnthropicRole, extractTextCountFromMessage } from "../vscode-lm-format" +import { + convertToVsCodeLmMessages, + convertToAnthropicRole, + extractTextCountFromMessage, + sanitizeSurrogates, +} from "../vscode-lm-format" // Mock crypto using Vitest vitest.stubGlobal("crypto", { @@ -174,6 +179,65 @@ describe("convertToVsCodeLmMessages", () => { const imagePlaceholder = result[0].content[1] as MockLanguageModelTextPart expect(imagePlaceholder.value).toContain("[Image (base64): image/png not supported by VSCode LM API]") }) + + it("should replace an unpaired surrogate in a tool_result with U+FFFD", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: `head\uD800tail`, + }, + ], + }, + ] + + const result = convertToVsCodeLmMessages(messages) + + const toolResult = result[0].content[0] as MockLanguageModelToolResultPart + expect(toolResult.content[0].value).toBe(`head\uFFFDtail`) + }) + + it("should keep a valid surrogate pair (emoji) intact through conversion", () => { + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: `hi \uD83D\uDE00` }] + + const result = convertToVsCodeLmMessages(messages) + + expect((result[0].content[0] as MockLanguageModelTextPart).value).toBe(`hi \uD83D\uDE00`) + }) +}) + +describe("sanitizeSurrogates", () => { + it("leaves plain ASCII unchanged", () => { + expect(sanitizeSurrogates("hello world")).toBe("hello world") + }) + + it("leaves valid surrogate pairs unchanged", () => { + // 😀 U+1F600 and 𐀀 U+10000 are astral-plane code points encoded as surrogate pairs. + expect(sanitizeSurrogates("a\uD83D\uDE00b\uD800\uDC00c")).toBe("a\uD83D\uDE00b\uD800\uDC00c") + }) + + it("replaces a lone high surrogate with U+FFFD", () => { + expect(sanitizeSurrogates("a\uD800b")).toBe("a\uFFFDb") + }) + + it("replaces a lone low surrogate with U+FFFD", () => { + expect(sanitizeSurrogates("a\uDC00b")).toBe("a\uFFFDb") + }) + + it("replaces a trailing lone high surrogate", () => { + expect(sanitizeSurrogates("abc\uD800")).toBe("abc\uFFFD") + }) + + it("replaces a reversed (low-then-high) pair as two lone surrogates", () => { + expect(sanitizeSurrogates("\uDC00\uD800")).toBe("\uFFFD\uFFFD") + }) + + it("returns empty and non-surrogate input unchanged", () => { + expect(sanitizeSurrogates("")).toBe("") + }) }) describe("convertToAnthropicRole", () => { diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts index 388197c2c2c..f02d16cceec 100644 --- a/src/api/transform/vscode-lm-format.ts +++ b/src/api/transform/vscode-lm-format.ts @@ -28,6 +28,23 @@ function asObjectSafe(value: any): object { } } +/** + * Replaces unpaired UTF-16 surrogate code units with the Unicode replacement character (U+FFFD). + * + * The VS Code LM backend forwards requests to model APIs that require valid UTF-8. A lone surrogate + * — e.g. left behind when some upstream step slices a string through an astral-plane character + * (emoji, CJK extension, etc.) — cannot be encoded as UTF-8, so the backend rejects the entire + * request with a 400 ("string contains an unpaired UTF-16 surrogate code point and cannot be + * encoded as valid UTF-8"). Valid surrogate pairs are matched by the lookahead/lookbehind and left + * untouched. The regex intentionally omits the `u` flag so it operates on UTF-16 code units. + */ +export function sanitizeSurrogates(text: string): string { + if (!text) { + return text + } + return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { if (part.type === "image") { return new vscode.LanguageModelTextPart( `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, ) } - return new vscode.LanguageModelTextPart(part.text) + return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) }) ?? [new vscode.LanguageModelTextPart("")]) return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts) @@ -89,7 +107,7 @@ export function convertToVsCodeLmMessages( `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, ) } - return new vscode.LanguageModelTextPart(part.text) + return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) }), ] @@ -122,7 +140,7 @@ export function convertToVsCodeLmMessages( if (part.type === "image") { return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]") } - return new vscode.LanguageModelTextPart(part.text) + return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) }), // Convert tool messages to ToolCallParts after text diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts index f43bbee0dc9..98cca973db6 100644 --- a/src/core/diff/strategies/multi-search-replace.ts +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -337,6 +337,12 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy { // Validate that search and replace content are not identical if (searchContent === replaceContent) { + // [apply_diff diagnostics] TEMP: confirm no-op block detection in multi-block diffs. + console.warn( + `[apply_diff diagnostics] identical SEARCH/REPLACE block` + + ` startLine=${replacement.startLine}` + + ` searchPreview=${JSON.stringify(searchContent.slice(0, 80))}`, + ) diffResults.push({ success: false, error: @@ -507,6 +513,13 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy { appliedCount++ } const finalContent = resultLines.join(lineEnding) + // [apply_diff diagnostics] TEMP: confirm appliedCount/failParts at the all-or-nothing gate. + console.warn( + `[apply_diff diagnostics] applyDiff summary` + + ` matches=${matches.length}` + + ` appliedCount=${appliedCount}` + + ` failParts=${diffResults.length}`, + ) if (appliedCount === 0) { return { success: false, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c4a8927e826..5c6aef9e046 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3486,6 +3486,27 @@ export class Task extends EventEmitter implements TaskLike { // Increment consecutive no-tool-use counter this.consecutiveNoToolUseCount++ + // Diagnostic logging for the "no tools used" path. This most often happens when a + // provider (e.g. vscode-lm) surfaces the model's tool call as plain text/XML markup + // (e.g. a leaked `` block) instead of a structured tool_use block, + // so we capture the assistant text and a heuristic flag to make the failure diagnosable. + const assistantText = this.assistantMessageContent + .map((block) => (block.type === "text" ? block.content : "")) + .join("") + const looksLikeToolCallAsText = /<(invoke|function_calls|tool_call|parameter)\b/i.test( + assistantText, + ) + console.warn(`[Task#${this.taskId}.${this.instanceId}] Model did not use a tool`, { + consecutiveNoToolUseCount: this.consecutiveNoToolUseCount, + provider: this.apiConfiguration.apiProvider ?? "unknown", + model: this.api.getModel().id, + blockTypes: this.assistantMessageContent.map((block) => block.type), + assistantTextLength: assistantText.length, + // Strong signal the model emitted a tool call as text rather than a structured tool_use block. + looksLikeToolCallAsText, + assistantTextPreview: assistantText.slice(0, 2000), + }) + // Only show error and count toward mistake limit after 2 consecutive failures if (this.consecutiveNoToolUseCount >= 2) { await this.say("error", "MODEL_NO_TOOLS_USED") diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 24968c0451e..b73334833a0 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -86,6 +86,15 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { let formattedError = "" if (diffResult.failParts && diffResult.failParts.length > 0) { + // [apply_diff diagnostics] TEMP: how many failParts exist, and how many are surfaced. + const failedParts = diffResult.failParts.filter((p) => !p.success) + console.warn( + `[apply_diff diagnostics] surfacing failParts` + + ` total=${diffResult.failParts.length}` + + ` failed=${failedParts.length}` + + ` (only the LAST failed message is shown to the model)`, + ) + for (const failPart of diffResult.failParts) { if (failPart.success) { continue diff --git a/src/package.json b/src/package.json index b8c4d45ec45..420be82a1bd 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.53.1", + "version": "3.53.2", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91",