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..7a6b79fa7c 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),