Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/core/src/tests/read-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
30 changes: 28 additions & 2 deletions packages/core/src/tools/read-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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/",
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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 {
Expand Down