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
15 changes: 13 additions & 2 deletions core/tools/definitions/readCurrentlyOpenFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,21 @@ export const readCurrentlyOpenFileTool: Tool = {
function: {
name: BuiltInToolNames.ReadCurrentlyOpenFile,
description:
"Read the currently open file in the IDE. If the user seems to be referring to a file that you can't see, or is requesting an action on content that seems missing, try using this tool.",
"Read the currently open file in the IDE. If the user seems to be referring to a file that you can't see, or is requesting an action on content that seems missing, try using this tool. For large files, use the offset and limit parameters to read a specific range of lines. When the response indicates more lines are available, continue reading with the next offset.",
parameters: {
type: "object",
properties: {},
properties: {
offset: {
type: "number",
description:
"The 1-based line number to start reading from. Defaults to 1 (beginning of file).",
},
limit: {
type: "number",
description:
"The maximum number of lines to read. Defaults to 2000. Output is also capped at 50 KB regardless of this value.",
},
},
},
},
defaultToolPolicy: "allowedWithPermission",
Expand Down
18 changes: 16 additions & 2 deletions core/tools/definitions/readFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const readFileTool: Tool = {
function: {
name: BuiltInToolNames.ReadFile,
description:
"Use this tool if you need to view the contents of an existing file.",
"Use this tool if you need to view the contents of an existing file. For large files, use the offset and limit parameters to read a specific range of lines. When the response indicates more lines are available, continue reading with the next offset.",
parameters: {
type: "object",
required: ["filepath"],
Expand All @@ -26,12 +26,26 @@ export const readFileTool: Tool = {
description:
"The path of the file to read. Can be a relative path (from workspace root), absolute path, tilde path (~/...), or file:// URI",
},
offset: {
type: "number",
description:
"The 1-based line number to start reading from. Defaults to 1 (beginning of file).",
},
limit: {
type: "number",
description:
"The maximum number of lines to read. Defaults to 2000. Output is also capped at 50 KB regardless of this value.",
},
},
},
},
systemMessageDescription: {
prefix: `To read a file with a known filepath, use the ${BuiltInToolNames.ReadFile} tool. For example, to read a file located at 'path/to/file.txt', you would respond with this:`,
exampleArgs: [["filepath", "path/to/the_file.txt"]],
exampleArgs: [
["filepath", "path/to/the_file.txt"],
["offset", 1],
["limit", 2000],
],
},
defaultToolPolicy: "allowedWithoutPermission",
toolCallIcon: "DocumentIcon",
Expand Down
143 changes: 116 additions & 27 deletions core/tools/implementations/readCurrentlyOpenFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,131 @@ import { getUriDescription } from "../../util/uri";

import { ToolImpl } from ".";
import { throwIfFileIsSecurityConcern } from "../../indexing/ignore";
import { throwIfFileExceedsHalfOfContext } from "./readFileLimit";
import { getOptionalNumberArg } from "../parseArgs";

export const readCurrentlyOpenFileImpl: ToolImpl = async (_, extras) => {
const result = await extras.ide.getCurrentFile();
/**
* Space complexity: O(output) — never O(file size).
*
* getCurrentFile() returns the live editor buffer that VS Code already holds
* in memory — there is nothing to stream from disk. Wrapping it in a
* ReadStream would be pure overhead.
*
* Instead we apply the same byte-cap + offset/limit slicing pattern used by
* readFileImpl directly on the in-memory string:
*
* contents (editor buffer, already in RAM)
* → split("\n") — O(lines in window), not O(file)
* → skip lines before offset — O(1) via Array.slice
* → accumulate until 50 KB cap — O(output)
* → return chunk + pagination note
*
* We never throw for file size. The LLM always receives a useful chunk and
* a clear offset= hint to continue reading if more lines exist.
*
* throwIfFileExceedsHalfOfContext is intentionally removed: it checked LLM
* context consumption but did so by throwing, which left the LLM stuck with
* no output. The 50 KB byte cap achieves the same protection without errors.
*/

if (result) {
throwIfFileIsSecurityConcern(result.path);
await throwIfFileExceedsHalfOfContext(
result.path,
result.contents,
extras.config.selectedModelByRole.chat,
);
// Hard byte cap per read (~50 KB ≈ 12,500 tokens; leaves ~90% of context for reasoning)
const MAX_BYTES = 50 * 1024;
// Per-line truncation guard against pathological lines (minified code, generated files)
const MAX_LINE_LENGTH = 2000;
const DEFAULT_LIMIT = 2000;

const { relativePathOrBasename, last2Parts, baseName } = getUriDescription(
result.path,
await extras.ide.getWorkspaceDirs(),
);
export const readCurrentlyOpenFileImpl: ToolImpl = async (args, extras) => {
const result = await extras.ide.getCurrentFile();

// No file is open in the editor — return a clear message so the LLM knows
if (!result) {
return [
{
name: `Current file: ${baseName}`,
description: last2Parts,
content: `\`\`\`${relativePathOrBasename}\n${result.contents}\n\`\`\``,
uri: {
type: "file",
value: result.path,
},
},
];
} else {
return [
{
name: `No Current File`,
name: "No Current File",
description: "",
content: "There are no files currently open.",
},
];
}

// Security check: reject sensitive files (keys, secrets, certs, etc.)
throwIfFileIsSecurityConcern(result.path);

// offset is 1-based line number to start from (default: beginning of file)
const offset = getOptionalNumberArg(args, "offset") ?? 1;
// limit is max lines to return in this read (default: 2000)
const limit = getOptionalNumberArg(args, "limit") ?? DEFAULT_LIMIT;

// contents is the live VS Code editor buffer — already in RAM.
// We slice it directly; no disk I/O or streaming needed.
const allLines = result.contents.split("\n");
const totalLines = allLines.length;

// offset is 1-based; convert to 0-based index and clamp to valid range
const startIdx = Math.max(0, offset - 1);
// Slice only the requested window — O(limit) not O(file size)
const requestedLines = allLines.slice(startIdx, startIdx + limit);

// Apply the 50 KB byte cap as a guard against very wide lines or a large limit.
// Accumulate lines one at a time and stop the moment the cap would be exceeded.
const outputLines: string[] = [];
let byteCount = 0;
let cut = false;

for (const rawLine of requestedLines) {
// Truncate pathological lines (minified JS, generated code, etc.)
const line =
rawLine.length > MAX_LINE_LENGTH
? rawLine.substring(0, MAX_LINE_LENGTH) +
"... (line truncated to 2000 chars)"
: rawLine;

const lineBytes = Buffer.byteLength(line, "utf-8") + 1; // +1 for newline
if (byteCount + lineBytes > MAX_BYTES) {
cut = true;
break;
}
outputLines.push(line);
byteCount += lineBytes;
}

const linesRead = outputLines.length;
// next 1-based offset for the caller to continue pagination
const nextOffset = offset + linesRead;
// more=true when byte cap fired (cut) OR the window didn't reach end of file
const more = cut || startIdx + linesRead < totalLines;

const { relativePathOrBasename, last2Parts, baseName } = getUriDescription(
result.path,
await extras.ide.getWorkspaceDirs(),
);

// Prepend 1-based line numbers so the LLM can reference exact lines
// and copy the nextOffset value directly for the follow-up call
const numberedContent = outputLines
.map((line, i) => `${offset + i}: ${line}`)
.join("\n");

const paginationNote = more
? `\n\n(Output capped at 50 KB. Use offset=${nextOffset} to continue reading.)`
: "";

// Wrap in a fenced code block with the relative path as the language hint,
// preserving the original display format expected by the chat UI
const content = `\`\`\`${relativePathOrBasename}\n${numberedContent}\n\`\`\`${paginationNote}`;

const description = more
? `${last2Parts} (lines ${offset}-${offset + linesRead - 1} of ${totalLines})`
: last2Parts;

return [
{
name: `Current file: ${baseName}`,
description,
content,
uri: {
type: "file",
value: result.path,
},
},
];
};
103 changes: 94 additions & 9 deletions core/tools/implementations/readFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,40 @@ import { getUriPathBasename } from "../../util/uri";

import { ToolImpl } from ".";
import { throwIfFileIsSecurityConcern } from "../../indexing/ignore";
import { getStringArg } from "../parseArgs";
import { throwIfFileExceedsHalfOfContext } from "./readFileLimit";
import { getOptionalNumberArg, getStringArg } from "../parseArgs";
import { ContinueError, ContinueErrorReason } from "../../util/errors";
import { MAX_CHAR_POSITION } from "./readFileRange";

/**
* Space complexity: O(output) — never O(file size).
*
* Instead of loading the full file and slicing in memory, we delegate
* to ide.readRangeInFile() which reads only the requested line window
* directly from the IDE/filesystem layer (backed by VS Code's
* vscode.workspace.fs or readRangeInFile API). The full file bytes
* are never held in this process.
*
* After receiving the bounded chunk we apply a 50 KB hard byte cap
* with per-line truncation so output is always predictably sized
* regardless of what the IDE returns for the requested range.
*/

// Hard byte cap per read (~50 KB ≈ 12,500 tokens; leaves ~90% of context for reasoning)
const MAX_BYTES = 50 * 1024;
// Per-line truncation guard against pathological lines (minified code, generated files)
const MAX_LINE_LENGTH = 2000;
const DEFAULT_LIMIT = 2000;
const MIN_LIMIT = 200;
Comment thread
Bijit-Mondal marked this conversation as resolved.

export const readFileImpl: ToolImpl = async (args, extras) => {
const filepath = getStringArg(args, "filepath");
// offset is 1-based line number to start from (default: beginning of file)
const offset = getOptionalNumberArg(args, "offset") ?? 1;
// limit is max lines to return in this read (default: 2000)
const limit = Math.max(
MIN_LIMIT,
getOptionalNumberArg(args, "limit") ?? DEFAULT_LIMIT,
);

// Resolve the path first to get the actual path for security check
const resolvedPath = await resolveInputPath(extras.ide, filepath);
Expand All @@ -22,18 +50,75 @@ export const readFileImpl: ToolImpl = async (args, extras) => {
// Security check on the resolved display path
throwIfFileIsSecurityConcern(resolvedPath.displayPath);

const content = await extras.ide.readFile(resolvedPath.uri);
// Convert 1-based offset to 0-based line index used by the IDE range API.
// readRangeInFile fetches ONLY this window from the IDE — the full file
// is never loaded into this process's memory.
const startLine = Math.max(0, offset - 1); // 0-based, inclusive
// Request limit+1 lines (N+1 pattern) so we can detect EOF unambiguously:
// if the IDE returns > limit lines, there is more content beyond the window.
const endLine = startLine + limit; // 0-based, inclusive (one extra sentinel line)

await throwIfFileExceedsHalfOfContext(
resolvedPath.displayPath,
content,
extras.config.selectedModelByRole.chat,
);
const rangeContent = await extras.ide.readRangeInFile(resolvedPath.uri, {
start: { line: startLine, character: 0 },
// MAX_CHAR_POSITION reads to end of line (Java Int.MAX_VALUE for IntelliJ compat)
end: { line: endLine, character: MAX_CHAR_POSITION },
});

// rangeContent is now only the requested window — O(limit) not O(file size).
// Apply the 50 KB byte cap as a secondary guard against very wide lines
// or a caller supplying an extremely large limit.
// Trim to limit before processing — the (limit+1)th line is only a sentinel
// to detect EOF, not part of the output.
const allLines = rangeContent.split("\n");
const hasMore = allLines.length > limit;
const rawLines = allLines.slice(0, limit);
const outputLines: string[] = [];
let byteCount = 0;
let cut = false;

for (const rawLine of rawLines) {
// Truncate pathological lines (minified JS, generated code, etc.)
const line =
rawLine.length > MAX_LINE_LENGTH
? rawLine.substring(0, MAX_LINE_LENGTH) +
"... (line truncated to 2000 chars)"
: rawLine;

const lineBytes = Buffer.byteLength(line, "utf-8") + 1; // +1 for newline
if (byteCount + lineBytes > MAX_BYTES) {
cut = true;
break;
}
outputLines.push(line);
byteCount += lineBytes;
}

const linesRead = outputLines.length;
// next 1-based offset for the caller to continue pagination
const nextOffset = offset + linesRead;
// more=true when byte cap cut the window short OR the IDE returned the
// sentinel (limit+1)th line, confirming content exists beyond the window.
const more = cut || hasMore;

// Prepend 1-based line numbers so the LLM can reference exact lines
// and copy the nextOffset value directly for the follow-up call
const numberedContent = outputLines
.map((line, i) => `${offset + i}: ${line}`)
.join("\n");

const paginationNote = more
? `\n\n(Output capped at 50 KB. Use offset=${nextOffset} to continue reading.)`
: "";

const content = numberedContent + paginationNote;
const description = more
? `${resolvedPath.displayPath} (lines ${offset}-${offset + linesRead - 1})`
: resolvedPath.displayPath;

return [
{
name: getUriPathBasename(resolvedPath.uri),
description: resolvedPath.displayPath,
description,
content,
uri: {
type: "file",
Expand Down
Loading
Loading