feat: add provider-specific file reading limits (maxReadFileLine)

Add a per-provider maxReadFileLine setting that allows users to override
the default 2000-line limit when reading files. This helps users with
slower local providers (e.g. CPU-based inference) avoid timeouts by
reducing prompt size.

Changes:
- Add maxReadFileLine to baseProviderSettingsSchema (packages/types)
- Update createReadFileTool() to reflect the effective limit in tool description
- Thread the setting through getNativeTools() and buildNativeToolsArray()
- Enforce the limit at execution time in ReadFileTool.processTextFile()
- Apply the limit to @ mention file reads via parseMentions chain
- Add MaxReadFileLineControl UI component in provider advanced settings
- Add English localization keys for the new setting
- Fix affected tests to account for new parameter

Closes #11407
This commit is contained in:
Roo Code 2026-02-11 16:35:11 +00:00
parent dcb33c47ad
commit 63e3f769ac
12 changed files with 113 additions and 17 deletions

View file

@ -189,6 +189,9 @@ const baseProviderSettingsSchema = z.object({
// Model verbosity. // Model verbosity.
verbosity: verbosityLevelsSchema.optional(), verbosity: verbosityLevelsSchema.optional(),
// File reading limits.
maxReadFileLine: z.number().int().min(1).optional(),
}) })
// Several of the providers share common model config properties. // Several of the providers share common model config properties.

View file

@ -223,6 +223,7 @@ describe("processUserContentMentions", () => {
false, // showRooIgnoredFiles should default to false false, // showRooIgnoredFiles should default to false
true, // includeDiagnosticMessages true, // includeDiagnosticMessages
50, // maxDiagnosticMessages 50, // maxDiagnosticMessages
undefined, // maxReadFileLine
) )
}) })
@ -251,6 +252,7 @@ describe("processUserContentMentions", () => {
false, false,
true, // includeDiagnosticMessages true, // includeDiagnosticMessages
50, // maxDiagnosticMessages 50, // maxDiagnosticMessages
undefined, // maxReadFileLine
) )
}) })
}) })

View file

@ -105,7 +105,8 @@ export interface ParseMentionsResult {
* Formats file content to look like a read_file tool result. * Formats file content to look like a read_file tool result.
* Includes Gemini-style truncation warning when content is truncated. * Includes Gemini-style truncation warning when content is truncated.
*/ */
function formatFileReadResult(filePath: string, result: ExtractTextResult): string { function formatFileReadResult(filePath: string, result: ExtractTextResult, maxReadFileLine?: number): string {
const effectiveLimit = maxReadFileLine ?? DEFAULT_LINE_LIMIT
const header = `[read_file for '${filePath}']` const header = `[read_file for '${filePath}']`
if (result.wasTruncated && result.linesShown) { if (result.wasTruncated && result.linesShown) {
@ -114,7 +115,7 @@ function formatFileReadResult(filePath: string, result: ExtractTextResult): stri
return `${header} return `${header}
IMPORTANT: File content truncated. IMPORTANT: File content truncated.
Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. Status: Showing lines ${start}-${end} of ${result.totalLines} total lines.
To read more: Use the read_file tool with offset=${nextOffset} and limit=${DEFAULT_LINE_LIMIT}. To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}.
File: ${filePath} File: ${filePath}
${result.content}` ${result.content}`
@ -134,6 +135,7 @@ export async function parseMentions(
showRooIgnoredFiles: boolean = false, showRooIgnoredFiles: boolean = false,
includeDiagnosticMessages: boolean = true, includeDiagnosticMessages: boolean = true,
maxDiagnosticMessages: number = 50, maxDiagnosticMessages: number = 50,
maxReadFileLine?: number,
): Promise<ParseMentionsResult> { ): Promise<ParseMentionsResult> {
const mentions: Set<string> = new Set() const mentions: Set<string> = new Set()
const validCommands: Map<string, Command> = new Map() const validCommands: Map<string, Command> = new Map()
@ -249,6 +251,7 @@ export async function parseMentions(
rooIgnoreController, rooIgnoreController,
showRooIgnoredFiles, showRooIgnoredFiles,
fileContextTracker, fileContextTracker,
maxReadFileLine,
) )
contentBlocks.push(fileResult) contentBlocks.push(fileResult)
} catch (error) { } catch (error) {
@ -331,6 +334,7 @@ async function getFileOrFolderContentWithMetadata(
rooIgnoreController?: any, rooIgnoreController?: any,
showRooIgnoredFiles: boolean = false, showRooIgnoredFiles: boolean = false,
fileContextTracker?: FileContextTracker, fileContextTracker?: FileContextTracker,
maxReadFileLine?: number,
): Promise<MentionContentBlock> { ): Promise<MentionContentBlock> {
const unescapedPath = unescapeSpaces(mentionPath) const unescapedPath = unescapeSpaces(mentionPath)
const absPath = path.resolve(cwd, unescapedPath) const absPath = path.resolve(cwd, unescapedPath)
@ -358,7 +362,8 @@ async function getFileOrFolderContentWithMetadata(
} }
} }
try { try {
const result = await extractTextFromFileWithMetadata(absPath) const effectiveLimit = maxReadFileLine ?? DEFAULT_LINE_LIMIT
const result = await extractTextFromFileWithMetadata(absPath, effectiveLimit)
// Track file context // Track file context
if (fileContextTracker) { if (fileContextTracker) {
@ -368,7 +373,7 @@ async function getFileOrFolderContentWithMetadata(
return { return {
type: "file", type: "file",
path: mentionPath, path: mentionPath,
content: formatFileReadResult(mentionPath, result), content: formatFileReadResult(mentionPath, result, maxReadFileLine),
metadata: { metadata: {
totalLines: result.totalLines, totalLines: result.totalLines,
returnedLines: result.returnedLines, returnedLines: result.returnedLines,
@ -415,8 +420,12 @@ async function getFileOrFolderContentWithMetadata(
try { try {
const isBinary = await isBinaryFile(absoluteFilePath).catch(() => false) const isBinary = await isBinaryFile(absoluteFilePath).catch(() => false)
if (!isBinary) { if (!isBinary) {
const result = await extractTextFromFileWithMetadata(absoluteFilePath) const effectiveFolderLimit = maxReadFileLine ?? DEFAULT_LINE_LIMIT
fileReadResults.push(formatFileReadResult(filePath.toPosix(), result)) const result = await extractTextFromFileWithMetadata(
absoluteFilePath,
effectiveFolderLimit,
)
fileReadResults.push(formatFileReadResult(filePath.toPosix(), result, maxReadFileLine))
} }
} catch (error) { } catch (error) {
// Skip files that can't be read // Skip files that can't be read

View file

@ -36,6 +36,7 @@ export async function processUserContentMentions({
showRooIgnoredFiles = false, showRooIgnoredFiles = false,
includeDiagnosticMessages = true, includeDiagnosticMessages = true,
maxDiagnosticMessages = 50, maxDiagnosticMessages = 50,
maxReadFileLine,
}: { }: {
userContent: Anthropic.Messages.ContentBlockParam[] userContent: Anthropic.Messages.ContentBlockParam[]
cwd: string cwd: string
@ -45,6 +46,7 @@ export async function processUserContentMentions({
showRooIgnoredFiles?: boolean showRooIgnoredFiles?: boolean
includeDiagnosticMessages?: boolean includeDiagnosticMessages?: boolean
maxDiagnosticMessages?: number maxDiagnosticMessages?: number
maxReadFileLine?: number
}): Promise<ProcessUserContentMentionsResult> { }): Promise<ProcessUserContentMentionsResult> {
// Track the first mode found from slash commands // Track the first mode found from slash commands
let commandMode: string | undefined let commandMode: string | undefined
@ -72,6 +74,7 @@ export async function processUserContentMentions({
showRooIgnoredFiles, showRooIgnoredFiles,
includeDiagnosticMessages, includeDiagnosticMessages,
maxDiagnosticMessages, maxDiagnosticMessages,
maxReadFileLine,
) )
// Capture the first mode found // Capture the first mode found
if (!commandMode && result.mode) { if (!commandMode && result.mode) {
@ -116,6 +119,7 @@ export async function processUserContentMentions({
showRooIgnoredFiles, showRooIgnoredFiles,
includeDiagnosticMessages, includeDiagnosticMessages,
maxDiagnosticMessages, maxDiagnosticMessages,
maxReadFileLine,
) )
// Capture the first mode found // Capture the first mode found
if (!commandMode && result.mode) { if (!commandMode && result.mode) {
@ -166,6 +170,7 @@ export async function processUserContentMentions({
showRooIgnoredFiles, showRooIgnoredFiles,
includeDiagnosticMessages, includeDiagnosticMessages,
maxDiagnosticMessages, maxDiagnosticMessages,
maxReadFileLine,
) )
// Capture the first mode found // Capture the first mode found
if (!commandMode && result.mode) { if (!commandMode && result.mode) {

View file

@ -32,6 +32,8 @@ export type { ReadFileToolOptions } from "./read_file"
export interface NativeToolsOptions { export interface NativeToolsOptions {
/** Whether the model supports image processing (default: false) */ /** Whether the model supports image processing (default: false) */
supportsImages?: boolean supportsImages?: boolean
/** Provider-specific override for the maximum lines returned per read */
maxReadFileLine?: number
} }
/** /**
@ -41,10 +43,11 @@ export interface NativeToolsOptions {
* @returns Array of native tool definitions * @returns Array of native tool definitions
*/ */
export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] { export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] {
const { supportsImages = false } = options const { supportsImages = false, maxReadFileLine } = options
const readFileOptions: ReadFileToolOptions = { const readFileOptions: ReadFileToolOptions = {
supportsImages, supportsImages,
maxReadFileLine,
} }
return [ return [

View file

@ -34,6 +34,8 @@ function getReadFileSupportsNote(supportsImages: boolean): string {
export interface ReadFileToolOptions { export interface ReadFileToolOptions {
/** Whether the model supports image processing (default: false) */ /** Whether the model supports image processing (default: false) */
supportsImages?: boolean supportsImages?: boolean
/** Provider-specific override for the maximum lines returned per read (default: DEFAULT_LINE_LIMIT) */
maxReadFileLine?: number
} }
// ─── Schema Builder ─────────────────────────────────────────────────────────── // ─── Schema Builder ───────────────────────────────────────────────────────────
@ -58,7 +60,10 @@ export interface ReadFileToolOptions {
* @returns Native tool definition for read_file * @returns Native tool definition for read_file
*/ */
export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool { export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool {
const { supportsImages = false } = options const { supportsImages = false, maxReadFileLine } = options
// Compute the effective line limit for the tool description
const effectiveLineLimit = maxReadFileLine ?? DEFAULT_LINE_LIMIT
// Build description based on capabilities // Build description based on capabilities
const descriptionIntro = const descriptionIntro =
@ -70,7 +75,7 @@ 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.` + ` 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.` ` 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 = ` By default, returns up to ${effectiveLineLimit} lines per file. Lines longer than ${MAX_LINE_LENGTH} characters are truncated.`
const description = const description =
descriptionIntro + descriptionIntro +
@ -125,7 +130,7 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch
}, },
limit: { limit: {
type: "integer", type: "integer",
description: `Maximum number of lines to return (slice mode, default: ${DEFAULT_LINE_LIMIT})`, description: `Maximum number of lines to return (slice mode, default: ${effectiveLineLimit})`,
}, },
indentation: { indentation: {
type: "object", type: "object",

View file

@ -2800,6 +2800,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
showRooIgnoredFiles, showRooIgnoredFiles,
includeDiagnosticMessages, includeDiagnosticMessages,
maxDiagnosticMessages, maxDiagnosticMessages,
maxReadFileLine: this.apiConfiguration?.maxReadFileLine,
}) })
// Switch mode if specified in a slash command's frontmatter // Switch mode if specified in a slash command's frontmatter

View file

@ -114,6 +114,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
// Build native tools with dynamic read_file tool based on settings. // Build native tools with dynamic read_file tool based on settings.
const nativeTools = getNativeTools({ const nativeTools = getNativeTools({
supportsImages, supportsImages,
maxReadFileLine: apiConfiguration?.maxReadFileLine,
}) })
// Filter native tools based on mode restrictions. // Filter native tools based on mode restrictions.

View file

@ -216,7 +216,8 @@ export class ReadFileTool extends BaseTool<"read_file"> {
// (they become U+FFFD replacement characters instead of throwing) // (they become U+FFFD replacement characters instead of throwing)
const buffer = await fs.readFile(fullPath) const buffer = await fs.readFile(fullPath)
const fileContent = buffer.toString("utf-8") const fileContent = buffer.toString("utf-8")
const result = this.processTextFile(fileContent, entry) const providerMaxReadFileLine = task.apiConfiguration?.maxReadFileLine
const result = this.processTextFile(fileContent, entry, providerMaxReadFileLine)
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
@ -265,20 +266,30 @@ export class ReadFileTool extends BaseTool<"read_file"> {
/** /**
* Process a text file according to the requested mode. * Process a text file according to the requested mode.
*
* @param content - The raw file content
* @param entry - The parsed file entry parameters
* @param providerMaxReadFileLine - Optional provider-level cap on returned lines
*/ */
private processTextFile(content: string, entry: InternalFileEntry): string { private processTextFile(content: string, entry: InternalFileEntry, providerMaxReadFileLine?: number): string {
const mode = entry.mode || "slice" const mode = entry.mode || "slice"
const defaultLimit = providerMaxReadFileLine ?? DEFAULT_LINE_LIMIT
if (mode === "indentation") { if (mode === "indentation") {
// Indentation mode: semantic block extraction // Indentation mode: semantic block extraction
// When anchor_line is not provided, default to offset (which defaults to 1) // When anchor_line is not provided, default to offset (which defaults to 1)
const anchorLine = entry.anchor_line ?? entry.offset ?? 1 const anchorLine = entry.anchor_line ?? entry.offset ?? 1
// Clamp the limit: if the provider has a max, enforce it even when the model requests more
const requestedLimit = entry.limit ?? defaultLimit
const effectiveLimit = providerMaxReadFileLine
? Math.min(requestedLimit, providerMaxReadFileLine)
: requestedLimit
const result = readWithIndentation(content, { const result = readWithIndentation(content, {
anchorLine, anchorLine,
maxLevels: entry.max_levels, maxLevels: entry.max_levels,
includeSiblings: entry.include_siblings, includeSiblings: entry.include_siblings,
includeHeader: entry.include_header, includeHeader: entry.include_header,
limit: entry.limit ?? DEFAULT_LINE_LIMIT, limit: effectiveLimit,
maxLines: entry.max_lines, maxLines: entry.max_lines,
}) })
@ -287,7 +298,6 @@ export class ReadFileTool extends BaseTool<"read_file"> {
if (result.wasTruncated && result.includedRanges.length > 0) { if (result.wasTruncated && result.includedRanges.length > 0) {
const [start, end] = result.includedRanges[0] const [start, end] = result.includedRanges[0]
const nextOffset = end + 1 const nextOffset = end + 1
const effectiveLimit = entry.limit ?? DEFAULT_LINE_LIMIT
// Put truncation warning at TOP (before content) to match @ mention format // Put truncation warning at TOP (before content) to match @ mention format
output = `IMPORTANT: File content truncated. output = `IMPORTANT: File content truncated.
Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. Status: Showing lines ${start}-${end} of ${result.totalLines} total lines.
@ -306,7 +316,11 @@ export class ReadFileTool extends BaseTool<"read_file"> {
// NOTE: read_file offset is 1-based externally; convert to 0-based for readWithSlice. // NOTE: read_file offset is 1-based externally; convert to 0-based for readWithSlice.
const offset1 = entry.offset ?? 1 const offset1 = entry.offset ?? 1
const offset0 = Math.max(0, offset1 - 1) const offset0 = Math.max(0, offset1 - 1)
const limit = entry.limit ?? DEFAULT_LINE_LIMIT // Clamp the limit: if the provider has a max, enforce it even when the model requests more
const requestedSliceLimit = entry.limit ?? defaultLimit
const limit = providerMaxReadFileLine
? Math.min(requestedSliceLimit, providerMaxReadFileLine)
: requestedSliceLimit
const result = readWithSlice(content, offset0, limit) const result = readWithSlice(content, offset0, limit)
@ -786,8 +800,10 @@ export class ReadFileTool extends BaseTool<"read_file"> {
} }
content = selectedLines.join("\n") content = selectedLines.join("\n")
} else { } else {
// Read with default limits using slice mode // Read with default limits using slice mode, clamped by provider setting
const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) const providerMaxReadFileLine = task.apiConfiguration?.maxReadFileLine
const legacyLimit = providerMaxReadFileLine ?? DEFAULT_LINE_LIMIT
const result = readWithSlice(rawContent, 0, legacyLimit)
content = result.content content = result.content
if (result.wasTruncated) { if (result.wasTruncated) {
content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]`

View file

@ -102,6 +102,7 @@ import { Verbosity } from "./Verbosity"
import { TodoListSettingsControl } from "./TodoListSettingsControl" import { TodoListSettingsControl } from "./TodoListSettingsControl"
import { TemperatureControl } from "./TemperatureControl" import { TemperatureControl } from "./TemperatureControl"
import { RateLimitSecondsControl } from "./RateLimitSecondsControl" import { RateLimitSecondsControl } from "./RateLimitSecondsControl"
import { MaxReadFileLineControl } from "./MaxReadFileLineControl"
import { ConsecutiveMistakeLimitControl } from "./ConsecutiveMistakeLimitControl" import { ConsecutiveMistakeLimitControl } from "./ConsecutiveMistakeLimitControl"
import { BedrockCustomArn } from "./providers/BedrockCustomArn" import { BedrockCustomArn } from "./providers/BedrockCustomArn"
import { RooBalanceDisplay } from "./providers/RooBalanceDisplay" import { RooBalanceDisplay } from "./providers/RooBalanceDisplay"
@ -786,6 +787,10 @@ const ApiOptions = ({
value={apiConfiguration.rateLimitSeconds || 0} value={apiConfiguration.rateLimitSeconds || 0}
onChange={(value) => setApiConfigurationField("rateLimitSeconds", value)} onChange={(value) => setApiConfigurationField("rateLimitSeconds", value)}
/> />
<MaxReadFileLineControl
value={apiConfiguration.maxReadFileLine}
onChange={(value) => setApiConfigurationField("maxReadFileLine", value)}
/>
<ConsecutiveMistakeLimitControl <ConsecutiveMistakeLimitControl
value={ value={
apiConfiguration.consecutiveMistakeLimit !== undefined apiConfiguration.consecutiveMistakeLimit !== undefined

View file

@ -0,0 +1,41 @@
import { useAppTranslation } from "@/i18n/TranslationContext"
interface MaxReadFileLineControlProps {
value: number | undefined
onChange: (value: number | undefined) => void
}
export const MaxReadFileLineControl = ({ value, onChange }: MaxReadFileLineControlProps) => {
const { t } = useAppTranslation()
return (
<div className="flex flex-col gap-1">
<label className="block font-medium mb-1">{t("settings:providers.maxReadFileLine.label")}</label>
<div className="flex items-center gap-2">
<input
type="number"
className="w-24 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border px-2 py-1 rounded text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={value ?? ""}
min={1}
max={100000}
placeholder="2000"
onChange={(e) => {
const raw = e.target.value
if (raw === "") {
onChange(undefined)
return
}
const newValue = parseInt(raw, 10)
if (!isNaN(newValue) && newValue >= 1) {
onChange(newValue)
}
}}
/>
<span>{t("settings:providers.maxReadFileLine.unit")}</span>
</div>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.maxReadFileLine.description")}
</div>
</div>
)
}

View file

@ -598,6 +598,11 @@
"label": "Rate limit", "label": "Rate limit",
"description": "Minimum time between API requests." "description": "Minimum time between API requests."
}, },
"maxReadFileLine": {
"label": "Max file read lines",
"unit": "lines",
"description": "Maximum number of lines returned when reading a file. Lower values reduce prompt size and help slower providers avoid timeouts. Leave empty to use the default (2000)."
},
"consecutiveMistakeLimit": { "consecutiveMistakeLimit": {
"label": "Error & Repetition Limit", "label": "Error & Repetition Limit",
"description": "Number of consecutive errors or repeated actions before showing 'Roo is having trouble' dialog. Set to 0 to disable this safety mechanism (it will never trigger).", "description": "Number of consecutive errors or repeated actions before showing 'Roo is having trouble' dialog. Set to 0 to disable this safety mechanism (it will never trigger).",