Skip to content

Commit b3fc58b

Browse files
committed
feat: add pagination support to read_file tool (EXT-617)
- Add offset, limit, format, and maxCharsPerLine params to FileEntry type - Create pagination helper functions with cat_n formatting - Implement bounded reads with stable line numbering - Add pagination metadata to tool output (nextOffset, reachedEof, truncated) - Update tool schema to support new pagination parameters - Maintain backward compatibility with existing behavior - Add comprehensive tests for pagination functionality Implements spec requirements: - Default limit of 2000 lines per request - 0-based offset for pagination - cat_n format with right-aligned line numbers - Line truncation with configurable maxCharsPerLine (default 2000) - Clear truncation signals and continuation hints - Pagination message includes next offset for continuation
1 parent 953c777 commit b3fc58b

File tree

5 files changed

+497
-42
lines changed

5 files changed

+497
-42
lines changed

packages/types/src/tool-params.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ export interface LineRange {
1010
export interface FileEntry {
1111
path: string
1212
lineRanges?: LineRange[]
13+
/** 0-based line offset for pagination (default: 0) */
14+
offset?: number
15+
/** Maximum number of lines to return (default: 2000) */
16+
limit?: number
17+
/** Output format (default: "cat_n") */
18+
format?: "cat_n"
19+
/** Maximum characters per line before truncation (default: 2000) */
20+
maxCharsPerLine?: number
1321
}
1422

1523
export interface Coordinate {

src/core/prompts/tools/native-tools/read_file.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,26 +45,36 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch
4545
descriptionIntro +
4646
"Structure: { files: [{ path: 'relative/path.ts'" +
4747
(partialReadsEnabled ? ", line_ranges: [[1, 50], [100, 150]]" : "") +
48-
" }] }. " +
48+
", offset: 0, limit: 2000 }] }. " +
4949
"The 'path' is required and relative to workspace. "
5050

5151
const optionalRangesDescription = partialReadsEnabled
5252
? "The 'line_ranges' is optional for reading specific sections. Each range is a [start, end] tuple (1-based inclusive). "
5353
: ""
5454

55+
const paginationDescription =
56+
"Pagination: Use 'offset' (0-based line offset, default: 0) and 'limit' (max lines to return, default: 2000) for bounded reads. When truncated, the response includes the next offset value for continuation. "
57+
5558
const examples = partialReadsEnabled
5659
? "Example single file: { files: [{ path: 'src/app.ts' }] }. " +
60+
"Example with pagination: { files: [{ path: 'src/app.ts', offset: 0, limit: 100 }] }. " +
5761
"Example with line ranges: { files: [{ path: 'src/app.ts', line_ranges: [[1, 50], [100, 150]] }] }. " +
5862
(isMultipleReadsEnabled
5963
? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }`
6064
: "")
6165
: "Example single file: { files: [{ path: 'src/app.ts' }] }. " +
66+
"Example with pagination: { files: [{ path: 'src/app.ts', offset: 0, limit: 100 }] }. " +
6267
(isMultipleReadsEnabled
6368
? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }`
6469
: "")
6570

6671
const description =
67-
baseDescription + optionalRangesDescription + getReadFileSupportsNote(supportsImages) + " " + examples
72+
baseDescription +
73+
optionalRangesDescription +
74+
paginationDescription +
75+
getReadFileSupportsNote(supportsImages) +
76+
" " +
77+
examples
6878

6979
// Build the properties object conditionally
7080
const fileProperties: Record<string, any> = {
@@ -89,9 +99,35 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch
8999
}
90100
}
91101

102+
// Add pagination parameters
103+
fileProperties.offset = {
104+
type: ["integer", "null"],
105+
description: "0-based line offset for pagination (default: 0). Use with limit for bounded reads.",
106+
}
107+
108+
fileProperties.limit = {
109+
type: ["integer", "null"],
110+
description:
111+
"Maximum number of lines to return (default: 2000). Response includes next offset for continuation.",
112+
}
113+
114+
fileProperties.format = {
115+
type: ["string", "null"],
116+
description: "Output format (default: 'cat_n'). Currently only 'cat_n' (line-numbered) is supported.",
117+
enum: ["cat_n", null],
118+
}
119+
120+
fileProperties.maxCharsPerLine = {
121+
type: ["integer", "null"],
122+
description:
123+
"Maximum characters per line before truncation (default: 2000). Truncated lines show '… [line truncated]' marker.",
124+
}
125+
92126
// When using strict mode, ALL properties must be in the required array
93127
// Optional properties are handled by having type: ["...", "null"]
94-
const fileRequiredProperties = partialReadsEnabled ? ["path", "line_ranges"] : ["path"]
128+
const fileRequiredProperties = partialReadsEnabled
129+
? ["path", "line_ranges", "offset", "limit", "format", "maxCharsPerLine"]
130+
: ["path", "offset", "limit", "format", "maxCharsPerLine"]
95131

96132
return {
97133
type: "function",

src/core/tools/ReadFileTool.ts

Lines changed: 83 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { FILE_READ_BUDGET_PERCENT, readFileWithTokenBudget } from "./helpers/fileTokenBudget"
3030
import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions"
3131
import { BaseTool, ToolCallbacks } from "./BaseTool"
32+
import { paginateLines, generatePaginationMessage } from "./helpers/paginationHelpers"
3233

3334
interface FileResult {
3435
path: string
@@ -41,6 +42,10 @@ interface FileResult {
4142
imageDataUrl?: string
4243
feedbackText?: string
4344
feedbackImages?: any[]
45+
offset?: number
46+
limit?: number
47+
format?: "cat_n"
48+
maxCharsPerLine?: number
4449
}
4550

4651
export class ReadFileTool extends BaseTool<"read_file"> {
@@ -79,6 +84,10 @@ export class ReadFileTool extends BaseTool<"read_file"> {
7984
path: entry.path,
8085
status: "pending",
8186
lineRanges: entry.lineRanges,
87+
offset: entry.offset,
88+
limit: entry.limit,
89+
format: entry.format,
90+
maxCharsPerLine: entry.maxCharsPerLine,
8291
}))
8392

8493
const updateFileResult = (filePath: string, updates: Partial<FileResult>) => {
@@ -449,57 +458,92 @@ export class ReadFileTool extends BaseTool<"read_file"> {
449458
continue
450459
}
451460

452-
const { id: modelId, info: modelInfo } = task.api.getModel()
453-
const { contextTokens } = task.getTokenUsage()
454-
const contextWindow = modelInfo.contextWindow
461+
// Check if pagination parameters are specified
462+
const usePagination = fileResult.offset !== undefined || fileResult.limit !== undefined
455463

456-
const maxOutputTokens =
457-
getModelMaxOutputTokens({
458-
modelId,
459-
model: modelInfo,
460-
settings: task.apiConfiguration,
461-
}) ?? ANTHROPIC_DEFAULT_MAX_TOKENS
464+
if (usePagination) {
465+
// Use line-based pagination
466+
const fileContent = await fs.readFile(fullPath, "utf-8")
467+
const allLines = fileContent.split("\n")
462468

463-
// Calculate available token budget (60% of remaining context)
464-
const remainingTokens = contextWindow - maxOutputTokens - (contextTokens || 0)
465-
const safeReadBudget = Math.floor(remainingTokens * FILE_READ_BUDGET_PERCENT)
469+
const paginationResult = paginateLines(allLines, {
470+
offset: fileResult.offset,
471+
limit: fileResult.limit,
472+
maxCharsPerLine: fileResult.maxCharsPerLine,
473+
})
466474

467-
let toolInfo = ""
475+
const paginationMessage = generatePaginationMessage(paginationResult, allLines.length)
476+
let toolInfo = ""
468477

469-
if (safeReadBudget <= 0) {
470-
// No budget available
471-
const notice = "No available context budget for file reading"
472-
toolInfo = `Note: ${notice}`
473-
} else {
474-
// Read file with incremental token counting
475-
const result = await readFileWithTokenBudget(fullPath, {
476-
budgetTokens: safeReadBudget,
477-
})
478+
if (paginationResult.linesReturned === 0) {
479+
toolInfo = `Note: ${paginationMessage}`
480+
} else {
481+
toolInfo = `${paginationResult.content}\n\nNote: ${paginationMessage}`
482+
}
478483

479-
const content = addLineNumbers(result.content)
484+
if (paginationResult.warnings.length > 0) {
485+
toolInfo += `\nWarnings: ${paginationResult.warnings.join("; ")}`
486+
}
487+
488+
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
480489

481-
if (!result.complete) {
482-
// File was truncated
483-
const notice = `File truncated: showing ${result.lineCount} lines (${result.tokenCount} tokens) due to context budget. Use line_range to read specific sections.`
484-
toolInfo =
485-
result.lineCount > 0
486-
? `Lines 1-${result.lineCount}:\n${content}\n\nNote: ${notice}`
487-
: `Note: ${notice}`
490+
updateFileResult(relPath, {
491+
nativeContent: `File: ${relPath}\n${toolInfo}`,
492+
})
493+
} else {
494+
// Use existing token budget-based reading
495+
const { id: modelId, info: modelInfo } = task.api.getModel()
496+
const { contextTokens } = task.getTokenUsage()
497+
const contextWindow = modelInfo.contextWindow
498+
499+
const maxOutputTokens =
500+
getModelMaxOutputTokens({
501+
modelId,
502+
model: modelInfo,
503+
settings: task.apiConfiguration,
504+
}) ?? ANTHROPIC_DEFAULT_MAX_TOKENS
505+
506+
// Calculate available token budget (60% of remaining context)
507+
const remainingTokens = contextWindow - maxOutputTokens - (contextTokens || 0)
508+
const safeReadBudget = Math.floor(remainingTokens * FILE_READ_BUDGET_PERCENT)
509+
510+
let toolInfo = ""
511+
512+
if (safeReadBudget <= 0) {
513+
// No budget available
514+
const notice = "No available context budget for file reading"
515+
toolInfo = `Note: ${notice}`
488516
} else {
489-
// Full file read
490-
if (result.lineCount === 0) {
491-
toolInfo = "Note: File is empty"
517+
// Read file with incremental token counting
518+
const result = await readFileWithTokenBudget(fullPath, {
519+
budgetTokens: safeReadBudget,
520+
})
521+
522+
const content = addLineNumbers(result.content)
523+
524+
if (!result.complete) {
525+
// File was truncated
526+
const notice = `File truncated: showing ${result.lineCount} lines (${result.tokenCount} tokens) due to context budget. Use line_range or offset/limit parameters to read specific sections.`
527+
toolInfo =
528+
result.lineCount > 0
529+
? `Lines 1-${result.lineCount}:\n${content}\n\nNote: ${notice}`
530+
: `Note: ${notice}`
492531
} else {
493-
toolInfo = `Lines 1-${result.lineCount}:\n${content}`
532+
// Full file read
533+
if (result.lineCount === 0) {
534+
toolInfo = "Note: File is empty"
535+
} else {
536+
toolInfo = `Lines 1-${result.lineCount}:\n${content}`
537+
}
494538
}
495539
}
496-
}
497540

498-
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
541+
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
499542

500-
updateFileResult(relPath, {
501-
nativeContent: `File: ${relPath}\n${toolInfo}`,
502-
})
543+
updateFileResult(relPath, {
544+
nativeContent: `File: ${relPath}\n${toolInfo}`,
545+
})
546+
}
503547
} catch (error) {
504548
const errorMsg = error instanceof Error ? error.message : String(error)
505549
updateFileResult(relPath, {

0 commit comments

Comments
 (0)