From 5099cccc5ed2fd0dc32937011f27b5a861db97a9 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 6 Aug 2026 18:08:06 +0900 Subject: [PATCH] fix(read): gate image attachment on model multimodality and size (#181) Only attach base64 image content to the conversation history when the active model supports multimodal input, and skip files above a size cap. Text-only models no longer accumulate megabytes of base64 in history, which previously led to context overflow (HTTP 400) on session resume. (cherry picked from commit 45ec1dc9e9a28252919fb65715aa8fa1f9e0a7a1) --- packages/core/src/tests/read-handler.test.ts | 71 ++++++++++++++++++++ packages/core/src/tools/read-handler.ts | 30 ++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/tests/read-handler.test.ts diff --git a/packages/core/src/tests/read-handler.test.ts b/packages/core/src/tests/read-handler.test.ts new file mode 100644 index 00000000..daeecb0f --- /dev/null +++ b/packages/core/src/tests/read-handler.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import type { ToolExecutionContext } from "../tools/executor"; +import { handleReadTool } from "../tools/read-handler"; + +const tempDirs: string[] = []; + +test.afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createContext(projectRoot: string, model: string): ToolExecutionContext { + return { + sessionId: "read-test", + projectRoot, + toolCall: { + id: "tool-call-id", + type: "function", + function: { + name: "read", + arguments: "{}", + }, + }, + createOpenAIClient: () => ({ + client: null, + model, + thinkingEnabled: false, + }), + }; +} + +function createPng(workspace: string): string { + const filePath = path.join(workspace, "test.png"); + fs.writeFileSync(filePath, Buffer.from("fakepngdata", "utf8")); + return filePath; +} + +test("read tool does not attach image content for text-only models (#181)", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-read-")); + tempDirs.push(workspace); + const filePath = createPng(workspace); + + const result = await handleReadTool({ file_path: filePath }, createContext(workspace, "deepseek-reasoner")); + + assert.equal(result.ok, true); + assert.equal(result.followUpMessages, undefined); + assert.match(result.output ?? "", /does not support images/); +}); + +test("read tool attaches image content for multimodal models (#181)", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-read-")); + tempDirs.push(workspace); + const filePath = createPng(workspace); + + const result = await handleReadTool({ file_path: filePath }, createContext(workspace, "gpt-4o")); + + assert.equal(result.ok, true); + assert.equal(result.output, "File loaded."); + assert.ok(Array.isArray(result.followUpMessages)); + const contentParams = result.followUpMessages?.[0]?.contentParams as unknown[] | undefined; + assert.ok(Array.isArray(contentParams)); + assert.equal((contentParams[0] as { type?: string }).type, "image_url"); +}); diff --git a/packages/core/src/tools/read-handler.ts b/packages/core/src/tools/read-handler.ts index 94be54b4..f9177aa4 100644 --- a/packages/core/src/tools/read-handler.ts +++ b/packages/core/src/tools/read-handler.ts @@ -3,6 +3,7 @@ import * as path from "path"; import ignore from "ignore"; import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; import { readTextFileWithMetadata } from "../common/file-utils"; +import { supportsMultimodal } from "../common/model-capabilities"; import { createFullFileSnippet, createSnippet, @@ -14,6 +15,9 @@ import { const DEFAULT_LINE_LIMIT = 2000; const MAX_LINE_LENGTH = 2000; const LINE_NUMBER_WIDTH = 6; +// Cap for attaching image payloads (as base64) to the conversation history so +// a single read cannot add hundreds of thousands of tokens. (#181) +const MAX_ATTACHED_IMAGE_BYTES = 15 * 1024 * 1024; const DEFAULT_GITIGNORE = [ "node_modules/", ".git/", @@ -179,15 +183,27 @@ export async function handleReadTool( timestamp: Math.floor(stat.mtimeMs), isPartialView: true, }); + // Gate base64 image inlining on the active model's multimodality and a + // size cap so text-only models never accumulate megabytes of base64 in + // the conversation history. (#181) + const llmContext = context.createOpenAIClient?.(); + const multimodal = supportsMultimodal(llmContext?.model ?? ""); + const tooLarge = buffer.length > MAX_ATTACHED_IMAGE_BYTES; + const followUpMessages = + multimodal && !tooLarge ? [buildImageFollowUpMessage(filePath, mime, buffer)] : undefined; return { ok: true, name: "read", - output: "File loaded.", + output: !multimodal + ? "File loaded (image content not attached: the active model does not support images)." + : tooLarge + ? `File loaded (image content not attached: file exceeds the ${formatBytes(MAX_ATTACHED_IMAGE_BYTES)} attach limit).` + : "File loaded.", metadata: { mime, bytes: buffer.length, }, - followUpMessages: [buildImageFollowUpMessage(filePath, mime, buffer)], + followUpMessages, }; } @@ -456,6 +472,16 @@ function getImageMimeType(ext: string): string { } } +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${bytes} B`; +} + function buildImageFollowUpMessage(filePath: string, mime: string, buffer: Buffer): ToolExecutionFollowUpMessage { const fileName = path.basename(filePath); return {