diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts
index 37fb851720..69743f18ee 100644
--- a/src/api/providers/__tests__/vscode-lm.spec.ts
+++ b/src/api/providers/__tests__/vscode-lm.spec.ts
@@ -60,7 +60,13 @@ vi.mock("vscode", () => {
})
import * as vscode from "vscode"
-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"
import { openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types"
@@ -270,6 +276,108 @@ describe("VsCodeLmHandler", () => {
})
})
+ describe("leaked tool-call recovery during streaming", () => {
+ const salvageTools = [
+ {
+ type: "function" as const,
+ function: {
+ name: "calculator",
+ description: "A simple calculator",
+ parameters: { type: "object", properties: { operation: { type: "string" } } },
+ },
+ },
+ ]
+
+ const streamTextParts = (parts: string[]) => {
+ mockLanguageModelChat.sendRequest.mockResolvedValueOnce({
+ stream: (async function* () {
+ for (const part of parts) {
+ yield new vscode.LanguageModelTextPart(part)
+ }
+ return
+ })(),
+ text: (async function* () {
+ yield parts.join("")
+ return
+ })(),
+ })
+ }
+
+ const collect = async (parts: string[]) => {
+ streamTextParts(parts)
+ const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }], {
+ taskId: "test-task",
+ tools: salvageTools,
+ })
+ const chunks = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+ return chunks
+ }
+
+ it("recovers a tool call the model streamed as raw invoke XML", async () => {
+ const chunks = await collect([
+ "Thinking. ",
+ 'add',
+ ])
+
+ expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "Thinking. " }])
+ expect(chunks.filter((chunk) => chunk.type === "tool_call")).toEqual([
+ {
+ type: "tool_call",
+ id: expect.stringContaining("vscodelm-salvaged-"),
+ name: "calculator",
+ arguments: JSON.stringify({ operation: "add" }),
+ },
+ ])
+ })
+
+ it("detects a marker split across stream chunks", async () => {
+ const chunks = await collect([
+ "abc sub',
+ ])
+
+ expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "abc " }])
+ expect(chunks.filter((chunk) => chunk.type === "tool_call")).toMatchObject([
+ { name: "calculator", arguments: JSON.stringify({ operation: "sub" }) },
+ ])
+ })
+
+ it("emits a carried tail as plain text when it never becomes a marker", async () => {
+ const chunks = await collect(["hello chunk.type === "text")).toEqual([
+ { type: "text", text: "hello " },
+ { type: "text", text: " chunk.type === "tool_call")).toBe(false)
+ })
+
+ it("buffers across chunks that arrive after the marker", async () => {
+ const chunks = await collect([
+ 'prose ',
+ '',
+ "mul",
+ "",
+ ])
+
+ expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "prose " }])
+ expect(chunks.filter((chunk) => chunk.type === "tool_call")).toMatchObject([
+ { name: "calculator", arguments: JSON.stringify({ operation: "mul" }) },
+ ])
+ })
+
+ it("keeps an invoke block for an unknown tool as literal text", async () => {
+ const block = '1'
+ const chunks = await collect([block])
+
+ expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: block }])
+ expect(chunks.some((chunk) => chunk.type === "tool_call")).toBe(false)
+ })
+ })
+
it("should handle native tool calls when tools are provided", async () => {
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = [
@@ -1075,3 +1183,306 @@ describe("VsCodeLmHandler", () => {
})
})
})
+
+describe("leaked tool-call recovery", () => {
+ // Builders keep the XML fixtures readable and prevent this file's own markup from being
+ // mistaken for a real tool call.
+ 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 an unwrapped leak preceded by a stray token", () => {
+ 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("")
+ })
+
+ // A lone surrogate cannot be encoded as UTF-8 and 400s the whole request.
+ it("never splits a surrogate pair across the removed middle", () => {
+ const pair = "\u{1F600}" // one astral char = high + low surrogate
+ const text = pair.repeat(400)
+ const result = middleOutTruncate(text, 200)
+
+ expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? {
+ 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("returns messages untouched when the budget is not a usable number", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ toolUseMessage("t1"),
+ toolResultMessage("t1", "Y".repeat(50_000)),
+ ]
+ const before = JSON.parse(JSON.stringify(messages))
+
+ expect(truncateToolResultsToFitWindow(messages, 0)).toBe(messages)
+ expect(truncateToolResultsToFitWindow(messages, Number.NaN)).toBe(messages)
+ expect(messages).toEqual(before)
+ })
+
+ it("truncates array-form tool_result content and preserves non-text parts", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ toolUseMessage("t1"),
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "t1",
+ content: [
+ { type: "text", text: "Z".repeat(50_000) },
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
+ ],
+ },
+ ],
+ } as unknown as Anthropic.Messages.MessageParam,
+ ]
+
+ truncateToolResultsToFitWindow(messages, 10_000)
+
+ const toolResult = findBlock(messages[1], "tool_result")
+ const parts = toolResult.content as Array<{ type: string; text?: string }>
+ expect(parts[0].type).toBe("text")
+ expect(parts[0].text).toContain("characters truncated")
+ expect(parts.some((part) => part.type === "image")).toBe(true)
+ })
+
+ it("ignores string content and skips messages that cannot hold tool_result blocks", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "a plain string turn" },
+ toolUseMessage("t1"),
+ {
+ role: "user",
+ content: [
+ { type: "tool_result", tool_use_id: "t1", content: "W".repeat(50_000) },
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
+ ],
+ } as unknown as Anthropic.Messages.MessageParam,
+ ]
+
+ truncateToolResultsToFitWindow(messages, 10_000)
+
+ expect(messages[0].content).toBe("a plain string turn")
+ expect(String(findBlock(messages[2], "tool_result").content)).toContain("characters truncated")
+ })
+
+ it("ignores a tool_result whose content is neither string nor array", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ toolUseMessage("t1"),
+ toolResultMessage("t1", "V".repeat(50_000)),
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "t2", content: undefined }],
+ } as unknown as Anthropic.Messages.MessageParam,
+ ]
+
+ truncateToolResultsToFitWindow(messages, 10_000)
+
+ expect(findBlock(messages[2], "tool_result").content).toBeUndefined()
+ expect(String(findBlock(messages[1], "tool_result").content)).toContain("characters truncated")
+ })
+
+ it("skips a tool_result already small enough to need no trimming", () => {
+ // Overage is tiny, so the largest block's target lands at its current length.
+ const messages: Anthropic.Messages.MessageParam[] = [
+ toolUseMessage("t1"),
+ toolResultMessage("t1", "U".repeat(3000)),
+ toolUseMessage("t2"),
+ toolResultMessage("t2", "T".repeat(2500)),
+ ]
+
+ truncateToolResultsToFitWindow(messages, 5600)
+
+ const first = String(findBlock(messages[1], "tool_result").content)
+ const second = String(findBlock(messages[3], "tool_result").content)
+ expect(first.length + second.length).toBeLessThanOrEqual(5600)
+ })
+
+ it("leaves a tool_result at or below the minimum size alone", () => {
+ const shortResult = "S".repeat(1500)
+ const messages: Anthropic.Messages.MessageParam[] = [
+ toolUseMessage("t1"),
+ toolResultMessage("t1", shortResult),
+ toolUseMessage("t2"),
+ toolResultMessage("t2", shortResult),
+ ]
+
+ truncateToolResultsToFitWindow(messages, 100)
+
+ expect(findBlock(messages[1], "tool_result").content).toBe(shortResult)
+ expect(findBlock(messages[3], "tool_result").content).toBe(shortResult)
+ })
+
+ 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 c657e6c0d6..d72779fbec 100644
--- a/src/api/providers/vscode-lm.ts
+++ b/src/api/providers/vscode-lm.ts
@@ -60,6 +60,251 @@ 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`. When this happens the
+ * assistant turn contains no tool_use block, so Zoo 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.
+ */
+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
+ * -------------------------------------------
+ * Copilot's backend enforces its own context window and, for third-party `sendRequest` callers,
+ * 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 "unexpected tool_use_id". To keep trimming on OUR side — where
+ * pairing is preserved — we shrink oversized `tool_result` payloads before sending. 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.
+ */
+
+/**
+ * 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. 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
+}
+
export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: vscode.LanguageModelChat | null
@@ -383,6 +628,19 @@ 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 ("unexpected tool_use_id"). 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),
@@ -398,6 +656,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 = {
@@ -421,9 +693,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 {
@@ -472,6 +775,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(
+ "Zoo 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 3265f2745b..18bac948e3 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", {
@@ -325,6 +330,37 @@ describe("convertToVsCodeLmMessages", () => {
})
})
+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 input unchanged", () => {
+ expect(sanitizeSurrogates("")).toBe("")
+ })
+})
+
describe("convertToAnthropicRole", () => {
it("should convert assistant role correctly", () => {
const result = convertToAnthropicRole(vscode.LanguageModelChatMessageRole.Assistant)
diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts
index 7ac51e024f..a03cba257c 100644
--- a/src/api/transform/vscode-lm-format.ts
+++ b/src/api/transform/vscode-lm-format.ts
@@ -28,6 +28,23 @@ function asObjectSafe(value: unknown): 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") {
if (part.source.type === "base64") {
@@ -82,7 +100,7 @@ export function convertToVsCodeLmMessages(
)
}
if (part.type === "text") {
- return new vscode.LanguageModelTextPart(part.text)
+ return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text))
}
return new vscode.LanguageModelTextPart("")
}) ?? [new vscode.LanguageModelTextPart("")])
@@ -102,7 +120,7 @@ export function convertToVsCodeLmMessages(
`[Image (${part.source.type}): not supported by VSCode LM API]`,
)
}
- return new vscode.LanguageModelTextPart(part.text)
+ return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text))
}),
]
@@ -135,7 +153,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