From 88ad6d82c2aea2b66045d0ebe35a184674c1bd7c Mon Sep 17 00:00:00 2001 From: Bertan Ari Date: Fri, 11 Sep 2026 08:44:12 -0700 Subject: [PATCH 1/2] fix(vscode-lm): trim oversized tool_results to fit the model context window Copilot's backend trims an over-window request without preserving tool_use/tool_result pairing, orphaning a tool_result and triggering a 400. Shrink oversized tool_result payloads middle-out on our side, and refuse a request that still cannot fit rather than send one we know is over-window. --- src/api/providers/__tests__/vscode-lm.spec.ts | 379 +++++++++++++++++- src/api/providers/vscode-lm.ts | 228 +++++++++++ 2 files changed, 606 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 423f119f14..aac4e76cdd 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -16,6 +16,14 @@ vi.mock("vscode", () => { ) {} } + class MockLanguageModelToolResultPart { + type = "tool_result" + constructor( + public callId: string, + public content: unknown[], + ) {} + } + return { workspace: { getConfiguration: vi.fn(() => ({ @@ -53,6 +61,7 @@ vi.mock("vscode", () => { }, LanguageModelTextPart: MockLanguageModelTextPart, LanguageModelToolCallPart: MockLanguageModelToolCallPart, + LanguageModelToolResultPart: MockLanguageModelToolResultPart, lm: { selectChatModels: vi.fn(), }, @@ -60,7 +69,8 @@ vi.mock("vscode", () => { }) import * as vscode from "vscode" -import { VsCodeLmHandler } from "../vscode-lm" +import { VsCodeLmHandler, middleOutTruncate, truncateToolResultsToFitWindow } from "../vscode-lm" +import { collectStream } from "../../../test-utils/stream" import type { ApiHandlerOptions } from "../../../shared/api" import type { Anthropic } from "@anthropic-ai/sdk" import { openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types" @@ -272,6 +282,135 @@ describe("VsCodeLmHandler", () => { }) }) + it("still trims oversized tool_results when the system prompt consumes most of the budget", async () => { + // A system prompt large enough to drive the raw budget negative; the clamp keeps trimming + // active for the case where the request is most oversized. + const systemPrompt = "S".repeat(handler.getCondenseContextWindow() * 3) + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "hi" }], + }, + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "some_tool", input: { a: 1 } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "X".repeat(50_000) }], + }, + ] + + // No sendRequest response is queued: the request must be refused before it is sent, and a + // queued-but-unconsumed response would leak into later tests. + // The clamped floor cannot be met once the tool_result bottoms out at its minimum, so the + // request must be refused rather than sent over-window (which orphans the tool_result). + const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task" }) + await expect( + (async () => { + for await (const _chunk of stream) { + // drain + } + })(), + ).rejects.toThrow(/too large for this model's context window/) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("refuses a request that exceeds a small positive raw budget below the trimming floor", async () => { + // The clamp to MIN_TOOL_RESULT_CHARS only keeps trimming productive; admission must still + // respect the raw budget, otherwise a conversation between the raw budget and the floor is + // sent over-window. Sized so the remaining content exceeds the raw budget but stays under + // the floor, and so no tool_result is large enough for trimming to shrink anything. + const targetRawBudgetChars = 1000 + const systemPrompt = "S".repeat( + Math.floor(handler.getCondenseContextWindow() * 0.8 * 3) - targetRawBudgetChars, + ) + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "some_tool", input: { a: 1 } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "X".repeat(1500) }], + }, + ] + + // No sendRequest response is queued: refusal must happen before the request is sent. + const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task" }) + await expect( + (async () => { + for await (const _chunk of stream) { + // drain + } + })(), + ).rejects.toThrow(/too large for this model's context window/) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("sends a request that fits within a small positive raw budget", async () => { + const targetRawBudgetChars = 1000 + const systemPrompt = "S".repeat( + Math.floor(handler.getCondenseContextWindow() * 0.8 * 3) - targetRawBudgetChars, + ) + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "Y".repeat(500) }], + }, + ] + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + return + })(), + text: (async function* () { + yield "ok" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task" }) + const chunks = await collectStream(stream) + + expect(mockLanguageModelChat.sendRequest).toHaveBeenCalled() + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + }) + + it("sends the request when trimming brings the conversation back under budget", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "some_tool", input: { a: 1 } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "X".repeat(500_000) }], + }, + ] + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + return + })(), + text: (async function* () { + yield "ok" + return + })(), + }) + + const stream = handler.createMessage("system", messages, { taskId: "test-task" }) + for await (const _chunk of stream) { + // drain + } + + const sent = JSON.stringify(mockLanguageModelChat.sendRequest.mock.calls[0][0]) + expect(sent).toContain("characters truncated") + expect(sent).not.toContain("X".repeat(400_000)) + }) + it("should handle native tool calls when tools are provided", async () => { const systemPrompt = "You are a helpful assistant" const messages: Anthropic.Messages.MessageParam[] = [ @@ -1077,3 +1216,241 @@ describe("VsCodeLmHandler", () => { }) }) }) + +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 62bcbf0c27..c595cc5495 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -66,6 +66,200 @@ function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode * } * ``` */ +/** + * 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. + * + * `client.countTokens` is the model's real tokenizer, but it counts only a string: it cannot price + * the tool schemas, image placeholders, or per-message framing the backend adds, so it cannot give + * the true total for the request we are about to send. It is also an async, per-call RPC, and the + * budget is needed for every message on every turn. We therefore keep a character estimate here + * and stay deliberately conservative — 3 chars/token rather than the ~4 typical of English — + * because the token-dense JSON, logs, and code that dominate oversized tool results tokenize to + * fewer characters per token than prose. Under-counting biases toward trimming too early, which is + * recoverable; over-counting sends an over-window request, which is not. + */ +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 + +/** + * Length charged for an image block. VS Code LM cannot carry image data, so + * `convertToVsCodeLmMessages` replaces each image with a sentence-long textual placeholder; this + * is that placeholder's approximate length. + */ +const IMAGE_PLACEHOLDER_CHARS = 64 + +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}` +} + +/** Estimated character cost of a whole conversation, using the same accounting as truncation. */ +export function estimateMessagesChars(messages: Anthropic.Messages.MessageParam[]): number { + return messages.reduce((sum, message) => sum + estimateContentChars(message.content), 0) +} + +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") { + // VS Code LM cannot send image data; convertToVsCodeLmMessages substitutes a textual + // placeholder, so charge that placeholder's real length rather than a token-sized guess. + total += IMAGE_PLACEHOLDER_CHARS + } + } + 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 @@ -389,6 +583,40 @@ 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 rawBudgetChars = + contextWindowTokens * VSCODE_LM_INPUT_BUDGET_FRACTION * VSCODE_LM_BUDGET_CHARS_PER_TOKEN - + systemPrompt.length - + toolSchemaChars + // A system prompt or tool schema large enough to consume the whole budget would leave a + // non-positive budget, which disables trimming exactly when the request is most oversized. + const messagesBudgetChars = Math.max(MIN_TOOL_RESULT_CHARS, rawBudgetChars) + truncateToolResultsToFitWindow(cleanedMessages, messagesBudgetChars) + + // Shrinking tool_results cannot always reach the budget: each keeps MIN_TOOL_RESULT_CHARS, + // and the excess may be non-tool content (a huge paste, tool_use inputs, or the system + // prompt) that we must not touch. Dropping messages here would orphan a tool_result from + // its tool_use — the exact 400 this guard exists to prevent — so fail loudly instead of + // sending a request we already know is over the window. + // Admission is judged against the RAW budget, not the clamped one: the clamp exists only + // to keep trimming productive, so accepting up to it would send a request the window + // genuinely cannot hold whenever the raw budget falls below MIN_TOOL_RESULT_CHARS. + const remainingChars = estimateMessagesChars(cleanedMessages) + if (remainingChars > rawBudgetChars) { + throw new Error( + "Zoo Code : The request is too large for this model's context window " + + `(estimated ${remainingChars.toLocaleString("en-US")} characters against a budget of ` + + `${Math.max(0, Math.floor(rawBudgetChars)).toLocaleString("en-US")}), and it cannot be reduced further without ` + + "breaking tool-call pairing. Condense the conversation or start a new task.", + ) + } + } + // Convert Anthropic messages to VS Code LM messages const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ vscode.LanguageModelChatMessage.Assistant(systemPrompt), From 7e4f7268d35898f69de1184b8821fd90ac2ef4fb Mon Sep 17 00:00:00 2001 From: Bertan Ari Date: Fri, 11 Sep 2026 08:53:54 -0700 Subject: [PATCH 2/2] fix(vscode-lm): restore em-dashes mangled during extraction --- src/api/providers/vscode-lm.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index c595cc5495..7a6b79fa7c 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -72,8 +72,8 @@ function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode * 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 + * 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. */ @@ -85,7 +85,7 @@ function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode * the tool schemas, image placeholders, or per-message framing the backend adds, so it cannot give * the true total for the request we are about to send. It is also an async, per-call RPC, and the * budget is needed for every message on every turn. We therefore keep a character estimate here - * and stay deliberately conservative — 3 chars/token rather than the ~4 typical of English — + * and stay deliberately conservative — 3 chars/token rather than the ~4 typical of English — * because the token-dense JSON, logs, and code that dominate oversized tool results tokenize to * fewer characters per token than prose. Under-counting biases toward trimming too early, which is * recoverable; over-counting sends an over-window request, which is not. @@ -160,7 +160,7 @@ export function middleOutTruncate(text: string, maxChars: number): string { 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 + // 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) @@ -206,7 +206,7 @@ function estimateContentChars(content: Anthropic.Messages.MessageParam["content" /** * 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 + * `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. */