diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 91b37f3d6d1..19a8659e545 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -164,6 +164,7 @@ export const globalSettingsSchema = z.object({ maxWorkspaceFiles: z.number().optional(), showRooIgnoredFiles: z.boolean().optional(), enableSubfolderRules: z.boolean().optional(), + maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), diff --git a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts index dded7fba50b..4d7fb80fdf1 100644 --- a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts @@ -1,122 +1,53 @@ -import type OpenAI from "openai" -import { createReadFileTool } from "../read_file" - -// Helper type to access function tools -type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } - -// Helper to get function definition from tool -const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function +import { createReadFileTool, DEFAULT_LINE_LIMIT } from "../read_file" describe("createReadFileTool", () => { - describe("single-file-per-call documentation", () => { - it("should indicate single-file-per-call and suggest parallel tool calls", () => { - const tool = createReadFileTool() - const description = getFunctionDef(tool).description - - expect(description).toContain("exactly one file per call") - expect(description).toContain("multiple parallel read_file calls") - }) + function getToolDesc(options = {}): string { + const tool = createReadFileTool(options) as any + return tool.function.description ?? "" + } + + function getToolLimitParamDesc(options = {}): string { + const tool = createReadFileTool(options) as any + return tool.function.parameters.properties.limit.description ?? "" + } + + it("uses DEFAULT_LINE_LIMIT in description when maxReadFileLine is undefined", () => { + const desc = getToolDesc() + expect(desc).toContain(`returns up to ${DEFAULT_LINE_LIMIT} lines per file`) + expect(desc).not.toContain("no line limit") }) - describe("indentation mode", () => { - it("should always include indentation mode in description", () => { - const tool = createReadFileTool() - const description = getFunctionDef(tool).description - - expect(description).toContain("indentation") - }) - - it("should always include indentation parameter in schema", () => { - const tool = createReadFileTool() - const schema = getFunctionDef(tool).parameters as any - - expect(schema.properties).toHaveProperty("indentation") - }) - - it("should include mode parameter in schema", () => { - const tool = createReadFileTool() - const schema = getFunctionDef(tool).parameters as any - - expect(schema.properties).toHaveProperty("mode") - expect(schema.properties.mode.enum).toContain("slice") - expect(schema.properties.mode.enum).toContain("indentation") - }) - - it("should include offset and limit parameters in schema", () => { - const tool = createReadFileTool() - const schema = getFunctionDef(tool).parameters as any - - expect(schema.properties).toHaveProperty("offset") - expect(schema.properties).toHaveProperty("limit") - }) + it("uses DEFAULT_LINE_LIMIT in description when maxReadFileLine is -1", () => { + const desc = getToolDesc({ maxReadFileLine: -1 }) + expect(desc).toContain(`returns up to ${DEFAULT_LINE_LIMIT} lines per file`) }) - describe("supportsImages option", () => { - it("should include image format documentation when supportsImages is true", () => { - const tool = createReadFileTool({ supportsImages: true }) - const description = getFunctionDef(tool).description - - expect(description).toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) - }) + it("indicates no limit in description when maxReadFileLine is 0", () => { + const desc = getToolDesc({ maxReadFileLine: 0 }) + expect(desc).toContain("no line limit") + expect(desc).not.toContain(`returns up to ${DEFAULT_LINE_LIMIT}`) - it("should not include image format documentation when supportsImages is false", () => { - const tool = createReadFileTool({ supportsImages: false }) - const description = getFunctionDef(tool).description - - expect(description).not.toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) - expect(description).toContain("may not handle other binary files properly") - }) - - it("should default supportsImages to false", () => { - const tool = createReadFileTool({}) - const description = getFunctionDef(tool).description - - expect(description).not.toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) - }) - - it("should always include PDF and DOCX support in description", () => { - const toolWithImages = createReadFileTool({ supportsImages: true }) - const toolWithoutImages = createReadFileTool({ supportsImages: false }) - - expect(getFunctionDef(toolWithImages).description).toContain( - "Supports text extraction from PDF and DOCX files", - ) - expect(getFunctionDef(toolWithoutImages).description).toContain( - "Supports text extraction from PDF and DOCX files", - ) - }) + const limitDesc = getToolLimitParamDesc({ maxReadFileLine: 0 }) + expect(limitDesc).toContain("no limit") }) - describe("tool structure", () => { - it("should have correct tool name", () => { - const tool = createReadFileTool() - - expect(getFunctionDef(tool).name).toBe("read_file") - }) + it("uses custom limit in description when maxReadFileLine is a positive number", () => { + const desc = getToolDesc({ maxReadFileLine: 500 }) + expect(desc).toContain("returns up to 500 lines per file") + // Should not mention DEFAULT_LINE_LIMIT as the line limit (but MAX_LINE_LENGTH=2000 chars is ok) + expect(desc).not.toContain(`up to ${DEFAULT_LINE_LIMIT} lines`) - it("should be a function type tool", () => { - const tool = createReadFileTool() - - expect(tool.type).toBe("function") - }) - - it("should have strict mode enabled", () => { - const tool = createReadFileTool() - - expect(getFunctionDef(tool).strict).toBe(true) - }) + const limitDesc = getToolLimitParamDesc({ maxReadFileLine: 500 }) + expect(limitDesc).toContain("500") + }) - it("should require path parameter", () => { - const tool = createReadFileTool() - const schema = getFunctionDef(tool).parameters as any + it("includes image support note when supportsImages is true", () => { + const desc = getToolDesc({ supportsImages: true }) + expect(desc).toContain("image files") + }) - expect(schema.required).toContain("path") - }) + it("does not include image support note when supportsImages is false", () => { + const desc = getToolDesc({ supportsImages: false }) + expect(desc).not.toContain("image files") }) }) diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d65..cfeb374aa18 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -31,6 +31,8 @@ export type { ReadFileToolOptions } from "./read_file" export interface NativeToolsOptions { /** Whether the model supports image processing (default: false) */ supportsImages?: boolean + /** Maximum line limit for read_file tool. -1 or undefined = DEFAULT_LINE_LIMIT, 0 = no limit */ + maxReadFileLine?: number } /** @@ -40,10 +42,11 @@ export interface NativeToolsOptions { * @returns Array of native tool definitions */ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] { - const { supportsImages = false } = options + const { supportsImages = false, maxReadFileLine } = options const readFileOptions: ReadFileToolOptions = { supportsImages, + maxReadFileLine, } return [ diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index af781556ef6..ff93cfbd0cf 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -34,6 +34,8 @@ function getReadFileSupportsNote(supportsImages: boolean): string { export interface ReadFileToolOptions { /** Whether the model supports image processing (default: false) */ supportsImages?: boolean + /** Maximum line limit for read_file tool. -1 or undefined = DEFAULT_LINE_LIMIT, 0 = no limit */ + maxReadFileLine?: number } // ─── Schema Builder ─────────────────────────────────────────────────────────── @@ -58,7 +60,21 @@ export interface ReadFileToolOptions { * @returns Native tool definition for read_file */ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool { - const { supportsImages = false } = options + const { supportsImages = false, maxReadFileLine } = options + + // Resolve the effective line limit for the tool description. + // -1 or undefined: use DEFAULT_LINE_LIMIT (2000) + // 0: no limit (read entire file) + // positive number: use that value + const hasNoLimit = maxReadFileLine === 0 + const effectiveLimit = + maxReadFileLine === undefined || maxReadFileLine === -1 + ? DEFAULT_LINE_LIMIT + : maxReadFileLine === 0 + ? undefined // no limit + : maxReadFileLine > 0 + ? maxReadFileLine + : DEFAULT_LINE_LIMIT // Build description based on capabilities const descriptionIntro = @@ -70,7 +86,9 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch ` PREFER indentation mode when you have a specific line number from search results, error messages, or definition lookups - it guarantees complete, syntactically valid code blocks without mid-function truncation.` + ` IMPORTANT: Indentation mode requires anchor_line to be useful. Without it, only header content (imports) is returned.` - const limitNote = ` By default, returns up to ${DEFAULT_LINE_LIMIT} lines per file. Lines longer than ${MAX_LINE_LENGTH} characters are truncated.` + const limitNote = hasNoLimit + ? ` Reads the entire file by default (no line limit). Lines longer than ${MAX_LINE_LENGTH} characters are truncated.` + : ` By default, returns up to ${effectiveLimit} lines per file. Lines longer than ${MAX_LINE_LENGTH} characters are truncated.` const description = descriptionIntro + @@ -125,7 +143,9 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch }, limit: { type: "integer", - description: `Maximum number of lines to return (slice mode, default: ${DEFAULT_LINE_LIMIT})`, + description: hasNoLimit + ? `Maximum number of lines to return (slice mode, default: no limit - reads entire file)` + : `Maximum number of lines to return (slice mode, default: ${effectiveLimit})`, }, indentation: { type: "object", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1d4320493a0..1248bd85333 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1670,6 +1670,7 @@ export class Task extends EventEmitter implements TaskLike { apiConfiguration, disabledTools: state?.disabledTools, modelInfo, + maxReadFileLine: state?.maxReadFileLine, includeAllToolsWithRestrictions: false, }) allTools = toolsResult.tools @@ -3858,6 +3859,7 @@ export class Task extends EventEmitter implements TaskLike { apiConfiguration, disabledTools: state?.disabledTools, modelInfo, + maxReadFileLine: state?.maxReadFileLine, includeAllToolsWithRestrictions: false, }) allTools = toolsResult.tools @@ -4072,6 +4074,7 @@ export class Task extends EventEmitter implements TaskLike { apiConfiguration, disabledTools: state?.disabledTools, modelInfo, + maxReadFileLine: state?.maxReadFileLine, includeAllToolsWithRestrictions: false, }) contextMgmtTools = toolsResult.tools @@ -4236,6 +4239,7 @@ export class Task extends EventEmitter implements TaskLike { apiConfiguration, disabledTools: state?.disabledTools, modelInfo, + maxReadFileLine: state?.maxReadFileLine, includeAllToolsWithRestrictions: supportsAllowedFunctionNames, }) allTools = toolsResult.tools diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index c32d8f6f9b2..2ce483f08ea 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -24,6 +24,8 @@ interface BuildToolsOptions { apiConfiguration: ProviderSettings | undefined disabledTools?: string[] modelInfo?: ModelInfo + /** Maximum line limit for read_file tool. -1 or undefined = DEFAULT_LINE_LIMIT, 0 = no limit */ + maxReadFileLine?: number /** * If true, returns all tools without mode filtering, but also includes * the list of allowed tool names for use with allowedFunctionNames. @@ -89,6 +91,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO apiConfiguration, disabledTools, modelInfo, + maxReadFileLine, includeAllToolsWithRestrictions, } = options @@ -111,6 +114,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO // Build native tools with dynamic read_file tool based on settings. const nativeTools = getNativeTools({ supportsImages, + maxReadFileLine, }) // Filter native tools based on mode restrictions. diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8ad6a3b33d1..99da35d5ae8 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -66,6 +66,27 @@ interface FileResult { entry?: InternalFileEntry } +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Resolve the effective line limit from the maxReadFileLine user setting. + * + * @param maxReadFileLine - The user setting value: + * - `-1` or `undefined`: use DEFAULT_LINE_LIMIT (2000) + * - `0`: no limit (read entire file) — returns `Infinity` + * - positive number: use that value as the limit + * @returns The resolved line limit + */ +export function resolveLineLimit(maxReadFileLine?: number): number { + if (maxReadFileLine === undefined || maxReadFileLine === null || maxReadFileLine === -1) { + return DEFAULT_LINE_LIMIT + } + if (maxReadFileLine === 0) { + return Infinity + } + return maxReadFileLine > 0 ? maxReadFileLine : DEFAULT_LINE_LIMIT +} + // ─── Tool Implementation ────────────────────────────────────────────────────── export class ReadFileTool extends BaseTool<"read_file"> { @@ -169,10 +190,17 @@ export class ReadFileTool extends BaseTool<"read_file"> { const imageMemoryTracker = new ImageMemoryTracker() const state = await task.providerRef.deref()?.getState() const { + maxReadFileLine, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, } = state ?? {} + // Resolve the effective line limit from the maxReadFileLine setting. + // -1 (default): use DEFAULT_LINE_LIMIT (2000) + // 0: no limit (read entire file) + // positive number: use that as the limit + const effectiveLineLimit = resolveLineLimit(maxReadFileLine) + for (const fileResult of fileResults) { if (fileResult.status !== "approved") continue @@ -216,7 +244,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { // (they become U+FFFD replacement characters instead of throwing) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") - const result = this.processTextFile(fileContent, entry) + const result = this.processTextFile(fileContent, entry, effectiveLineLimit) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) @@ -266,7 +294,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { /** * Process a text file according to the requested mode. */ - private processTextFile(content: string, entry: InternalFileEntry): string { + private processTextFile( + content: string, + entry: InternalFileEntry, + effectiveLineLimit: number = DEFAULT_LINE_LIMIT, + ): string { const mode = entry.mode || "slice" if (mode === "indentation") { @@ -278,7 +310,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { maxLevels: entry.max_levels, includeSiblings: entry.include_siblings, includeHeader: entry.include_header, - limit: entry.limit ?? DEFAULT_LINE_LIMIT, + limit: entry.limit ?? effectiveLineLimit, maxLines: entry.max_lines, }) @@ -287,7 +319,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (result.wasTruncated && result.includedRanges.length > 0) { const [start, end] = result.includedRanges[0] const nextOffset = end + 1 - const effectiveLimit = entry.limit ?? DEFAULT_LINE_LIMIT + const effectiveLimit = entry.limit ?? effectiveLineLimit // Put truncation warning at TOP (before content) to match @ mention format output = `IMPORTANT: File content truncated. Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. @@ -306,7 +338,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { // NOTE: read_file offset is 1-based externally; convert to 0-based for readWithSlice. const offset1 = entry.offset ?? 1 const offset0 = Math.max(0, offset1 - 1) - const limit = entry.limit ?? DEFAULT_LINE_LIMIT + const limit = entry.limit ?? effectiveLineLimit const result = readWithSlice(content, offset0, limit) @@ -786,8 +818,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { } content = selectedLines.join("\n") } else { - // Read with default limits using slice mode - const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) + // Read with effective limits using slice mode + const legacyState = await task.providerRef.deref()?.getState() + const legacyLineLimit = resolveLineLimit(legacyState?.maxReadFileLine) + const result = readWithSlice(rawContent, 0, legacyLineLimit) content = result.content if (result.wasTruncated) { content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` diff --git a/src/core/tools/__tests__/resolveLineLimit.spec.ts b/src/core/tools/__tests__/resolveLineLimit.spec.ts new file mode 100644 index 00000000000..3010c654fd2 --- /dev/null +++ b/src/core/tools/__tests__/resolveLineLimit.spec.ts @@ -0,0 +1,30 @@ +import { resolveLineLimit } from "../ReadFileTool" +import { DEFAULT_LINE_LIMIT } from "../../prompts/tools/native-tools/read_file" + +describe("resolveLineLimit", () => { + it("returns DEFAULT_LINE_LIMIT when undefined", () => { + expect(resolveLineLimit(undefined)).toBe(DEFAULT_LINE_LIMIT) + }) + + it("returns DEFAULT_LINE_LIMIT when -1 (default setting)", () => { + expect(resolveLineLimit(-1)).toBe(DEFAULT_LINE_LIMIT) + }) + + it("returns Infinity when 0 (no limit / read entire file)", () => { + expect(resolveLineLimit(0)).toBe(Infinity) + }) + + it("returns the positive value as-is", () => { + expect(resolveLineLimit(500)).toBe(500) + expect(resolveLineLimit(10000)).toBe(10000) + }) + + it("returns DEFAULT_LINE_LIMIT for negative values other than -1", () => { + expect(resolveLineLimit(-5)).toBe(DEFAULT_LINE_LIMIT) + }) + + it("returns DEFAULT_LINE_LIMIT when null-ish (null cast as number)", () => { + // In practice, state?.maxReadFileLine could be null + expect(resolveLineLimit(null as unknown as number)).toBe(DEFAULT_LINE_LIMIT) + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 231e2794c52..50157677756 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2180,6 +2180,7 @@ export class ClineProvider showRooIgnoredFiles, enableSubfolderRules, language, + maxReadFileLine, maxImageFileSize, maxTotalImageSize, historyPreviewCollapsed, @@ -2305,6 +2306,7 @@ export class ClineProvider enableSubfolderRules: enableSubfolderRules ?? false, language: language ?? formatLanguage(vscode.env.language), renderContext: this.renderContext, + maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, settingsImportedAt: this.settingsImportedAt, @@ -2529,6 +2531,7 @@ export class ClineProvider telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, enableSubfolderRules: stateValues.enableSubfolderRules ?? false, + maxReadFileLine: stateValues.maxReadFileLine ?? -1, maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,