mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(read_file): implement CodexCLI-inspired slice/indentation reading modes
Replace line_ranges with new offset/limit/mode API for improved file reading: - Slice mode: simple line-by-line reading with offset pagination - Indentation mode: smart code block extraction based on indentation - Add rich metadata for pagination awareness (hasMoreBefore/After, etc.) - Remove deprecated truncateDefinitions helper - Update tool definition with new parameters and examples - Default maxReadFileLine from 500 to 2000 lines
This commit is contained in:
parent
802b40a790
commit
5fc2700682
14 changed files with 1284 additions and 629 deletions
|
|
@ -2,14 +2,37 @@
|
|||
* Tool parameter type definitions for native protocol
|
||||
*/
|
||||
|
||||
export interface LineRange {
|
||||
start: number
|
||||
end: number
|
||||
/**
|
||||
* Configuration for indentation-aware block extraction
|
||||
*/
|
||||
export interface IndentationConfig {
|
||||
/** The line to anchor the block expansion from (defaults to offset) */
|
||||
anchorLine?: number
|
||||
/** Maximum indentation depth to collect; 0 = unlimited */
|
||||
maxLevels?: number
|
||||
/** Whether to include sibling blocks at same indentation level */
|
||||
includeSiblings?: boolean
|
||||
/** Whether to include comment headers above the anchor block */
|
||||
includeHeader?: boolean
|
||||
/** Hard cap on returned lines (defaults to limit) */
|
||||
maxLines?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Read mode for file content extraction
|
||||
*/
|
||||
export type ReadMode = "slice" | "indentation"
|
||||
|
||||
export interface FileEntry {
|
||||
path: string
|
||||
lineRanges?: LineRange[]
|
||||
/** 1-indexed line number to start reading from (default: 1) */
|
||||
offset?: number
|
||||
/** Maximum number of lines to return (default: 2000) */
|
||||
limit?: number
|
||||
/** Reading mode: "slice" for simple reading, "indentation" for smart block extraction */
|
||||
mode?: ReadMode
|
||||
/** Configuration for indentation mode */
|
||||
indentation?: IndentationConfig
|
||||
}
|
||||
|
||||
export interface Coordinate {
|
||||
|
|
|
|||
|
|
@ -298,40 +298,75 @@ export class NativeToolCallParser {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert raw file entries from API (with line_ranges) to FileEntry objects
|
||||
* (with lineRanges). Handles multiple formats for compatibility:
|
||||
* Convert raw file entries from API to FileEntry objects.
|
||||
* Supports the new slice/indentation API:
|
||||
*
|
||||
* New tuple format: { path: string, line_ranges: [[1, 50], [100, 150]] }
|
||||
* Object format: { path: string, line_ranges: [{ start: 1, end: 50 }] }
|
||||
* Legacy string format: { path: string, line_ranges: ["1-50"] }
|
||||
*
|
||||
* Returns: { path: string, lineRanges: [{ start: 1, end: 50 }] }
|
||||
* { path: string, offset?: number, limit?: number, mode?: "slice" | "indentation", indentation?: {...} }
|
||||
*/
|
||||
private static convertFileEntries(files: any[]): FileEntry[] {
|
||||
return files.map((file: any) => {
|
||||
const entry: FileEntry = { path: file.path }
|
||||
if (file.line_ranges && Array.isArray(file.line_ranges)) {
|
||||
entry.lineRanges = file.line_ranges
|
||||
.map((range: any) => {
|
||||
// Handle tuple format: [start, end]
|
||||
if (Array.isArray(range) && range.length >= 2) {
|
||||
return { start: Number(range[0]), end: Number(range[1]) }
|
||||
}
|
||||
// Handle object format: { start: number, end: number }
|
||||
if (typeof range === "object" && range !== null && "start" in range && "end" in range) {
|
||||
return { start: Number(range.start), end: Number(range.end) }
|
||||
}
|
||||
// Handle legacy string format: "1-50"
|
||||
if (typeof range === "string") {
|
||||
const match = range.match(/^(\d+)-(\d+)$/)
|
||||
if (match) {
|
||||
return { start: parseInt(match[1], 10), end: parseInt(match[2], 10) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// Map offset parameter
|
||||
if (file.offset !== undefined) {
|
||||
const offset = Number(file.offset)
|
||||
if (!isNaN(offset) && offset > 0) {
|
||||
entry.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// Map limit parameter
|
||||
if (file.limit !== undefined) {
|
||||
const limit = Number(file.limit)
|
||||
if (!isNaN(limit) && limit > 0) {
|
||||
entry.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
// Map mode parameter
|
||||
if (file.mode === "slice" || file.mode === "indentation") {
|
||||
entry.mode = file.mode
|
||||
}
|
||||
|
||||
// Map indentation configuration
|
||||
if (file.indentation && typeof file.indentation === "object") {
|
||||
const indent = file.indentation
|
||||
const indentConfig: FileEntry["indentation"] = {}
|
||||
|
||||
if (indent.anchor_line !== undefined) {
|
||||
const anchorLine = Number(indent.anchor_line)
|
||||
if (!isNaN(anchorLine) && anchorLine > 0) {
|
||||
indentConfig.anchorLine = anchorLine
|
||||
}
|
||||
}
|
||||
|
||||
if (indent.max_levels !== undefined) {
|
||||
const maxLevels = Number(indent.max_levels)
|
||||
if (!isNaN(maxLevels) && maxLevels >= 0) {
|
||||
indentConfig.maxLevels = maxLevels
|
||||
}
|
||||
}
|
||||
|
||||
if (indent.include_siblings !== undefined) {
|
||||
indentConfig.includeSiblings = Boolean(indent.include_siblings)
|
||||
}
|
||||
|
||||
if (indent.include_header !== undefined) {
|
||||
indentConfig.includeHeader = Boolean(indent.include_header)
|
||||
}
|
||||
|
||||
if (indent.max_lines !== undefined) {
|
||||
const maxLines = Number(indent.max_lines)
|
||||
if (!isNaN(maxLines) && maxLines > 0) {
|
||||
indentConfig.maxLines = maxLines
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(indentConfig).length > 0) {
|
||||
entry.indentation = indentConfig
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ describe("NativeToolCallParser", () => {
|
|||
|
||||
describe("parseToolCall", () => {
|
||||
describe("read_file tool", () => {
|
||||
it("should handle line_ranges as tuples (new format)", () => {
|
||||
it("should handle offset and mode parameters", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
|
|
@ -16,10 +16,8 @@ describe("NativeToolCallParser", () => {
|
|||
files: [
|
||||
{
|
||||
path: "src/core/task/Task.ts",
|
||||
line_ranges: [
|
||||
[1920, 1990],
|
||||
[2060, 2120],
|
||||
],
|
||||
offset: 100,
|
||||
mode: "slice",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -32,18 +30,16 @@ describe("NativeToolCallParser", () => {
|
|||
if (result?.type === "tool_use") {
|
||||
expect(result.nativeArgs).toBeDefined()
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
files: Array<{ path: string; offset?: number; mode?: string }>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].path).toBe("src/core/task/Task.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 1920, end: 1990 },
|
||||
{ start: 2060, end: 2120 },
|
||||
])
|
||||
expect(nativeArgs.files[0].offset).toBe(100)
|
||||
expect(nativeArgs.files[0].mode).toBe("slice")
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle line_ranges as strings (legacy format)", () => {
|
||||
it("should handle indentation mode with configuration", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
|
|
@ -51,7 +47,13 @@ describe("NativeToolCallParser", () => {
|
|||
files: [
|
||||
{
|
||||
path: "src/core/task/Task.ts",
|
||||
line_ranges: ["1920-1990", "2060-2120"],
|
||||
offset: 50,
|
||||
mode: "indentation",
|
||||
indentation: {
|
||||
anchor_line: 55,
|
||||
max_levels: 2,
|
||||
include_siblings: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -64,18 +66,24 @@ describe("NativeToolCallParser", () => {
|
|||
if (result?.type === "tool_use") {
|
||||
expect(result.nativeArgs).toBeDefined()
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
files: Array<{
|
||||
path: string
|
||||
offset?: number
|
||||
mode?: string
|
||||
indentation?: { anchorLine?: number; maxLevels?: number; includeSiblings?: boolean }
|
||||
}>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].path).toBe("src/core/task/Task.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 1920, end: 1990 },
|
||||
{ start: 2060, end: 2120 },
|
||||
])
|
||||
expect(nativeArgs.files[0].offset).toBe(50)
|
||||
expect(nativeArgs.files[0].mode).toBe("indentation")
|
||||
expect(nativeArgs.files[0].indentation?.anchorLine).toBe(55)
|
||||
expect(nativeArgs.files[0].indentation?.maxLevels).toBe(2)
|
||||
expect(nativeArgs.files[0].indentation?.includeSiblings).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle files without line_ranges", () => {
|
||||
it("should handle files without offset or mode (defaults)", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
|
|
@ -94,15 +102,16 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
files: Array<{ path: string; offset?: number; mode?: string }>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].path).toBe("src/utils.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toBeUndefined()
|
||||
expect(nativeArgs.files[0].offset).toBeUndefined()
|
||||
expect(nativeArgs.files[0].mode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle multiple files with different line_ranges", () => {
|
||||
it("should handle multiple files with different offsets and modes", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
|
|
@ -110,11 +119,14 @@ describe("NativeToolCallParser", () => {
|
|||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
line_ranges: ["1-50"],
|
||||
offset: 1,
|
||||
mode: "slice",
|
||||
},
|
||||
{
|
||||
path: "file2.ts",
|
||||
line_ranges: ["100-150", "200-250"],
|
||||
offset: 100,
|
||||
mode: "indentation",
|
||||
indentation: { max_levels: 1 },
|
||||
},
|
||||
{
|
||||
path: "file3.ts",
|
||||
|
|
@ -129,19 +141,25 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
files: Array<{
|
||||
path: string
|
||||
offset?: number
|
||||
mode?: string
|
||||
indentation?: { maxLevels?: number }
|
||||
}>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(3)
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([{ start: 1, end: 50 }])
|
||||
expect(nativeArgs.files[1].lineRanges).toEqual([
|
||||
{ start: 100, end: 150 },
|
||||
{ start: 200, end: 250 },
|
||||
])
|
||||
expect(nativeArgs.files[2].lineRanges).toBeUndefined()
|
||||
expect(nativeArgs.files[0].offset).toBe(1)
|
||||
expect(nativeArgs.files[0].mode).toBe("slice")
|
||||
expect(nativeArgs.files[1].offset).toBe(100)
|
||||
expect(nativeArgs.files[1].mode).toBe("indentation")
|
||||
expect(nativeArgs.files[1].indentation?.maxLevels).toBe(1)
|
||||
expect(nativeArgs.files[2].offset).toBeUndefined()
|
||||
expect(nativeArgs.files[2].mode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("should filter out invalid line_range strings", () => {
|
||||
it("should ignore invalid offset and mode values", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
|
|
@ -149,7 +167,8 @@ describe("NativeToolCallParser", () => {
|
|||
files: [
|
||||
{
|
||||
path: "file.ts",
|
||||
line_ranges: ["1-50", "invalid", "100-200", "abc-def"],
|
||||
offset: -10, // Invalid - should be ignored
|
||||
mode: "invalid_mode", // Invalid - should be ignored
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -161,12 +180,11 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
files: Array<{ path: string; offset?: number; mode?: string }>
|
||||
}
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 1, end: 50 },
|
||||
{ start: 100, end: 200 },
|
||||
])
|
||||
// Invalid values should not be passed through
|
||||
expect(nativeArgs.files[0].offset).toBeUndefined()
|
||||
expect(nativeArgs.files[0].mode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -174,7 +192,7 @@ describe("NativeToolCallParser", () => {
|
|||
|
||||
describe("processStreamingChunk", () => {
|
||||
describe("read_file tool", () => {
|
||||
it("should convert line_ranges strings to lineRanges objects during streaming", () => {
|
||||
it("should parse offset and mode during streaming", () => {
|
||||
const id = "toolu_streaming_123"
|
||||
NativeToolCallParser.startStreamingToolCall(id, "read_file")
|
||||
|
||||
|
|
@ -183,7 +201,8 @@ describe("NativeToolCallParser", () => {
|
|||
files: [
|
||||
{
|
||||
path: "src/test.ts",
|
||||
line_ranges: ["10-20", "30-40"],
|
||||
offset: 50,
|
||||
mode: "slice",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -194,20 +213,18 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result).not.toBeNull()
|
||||
expect(result?.nativeArgs).toBeDefined()
|
||||
const nativeArgs = result?.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
files: Array<{ path: string; offset?: number; mode?: string }>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 10, end: 20 },
|
||||
{ start: 30, end: 40 },
|
||||
])
|
||||
expect(nativeArgs.files[0].offset).toBe(50)
|
||||
expect(nativeArgs.files[0].mode).toBe("slice")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("finalizeStreamingToolCall", () => {
|
||||
describe("read_file tool", () => {
|
||||
it("should convert line_ranges strings to lineRanges objects on finalize", () => {
|
||||
it("should parse offset and mode on finalize", () => {
|
||||
const id = "toolu_finalize_123"
|
||||
NativeToolCallParser.startStreamingToolCall(id, "read_file")
|
||||
|
||||
|
|
@ -218,7 +235,9 @@ describe("NativeToolCallParser", () => {
|
|||
files: [
|
||||
{
|
||||
path: "finalized.ts",
|
||||
line_ranges: ["500-600"],
|
||||
offset: 500,
|
||||
mode: "indentation",
|
||||
indentation: { anchor_line: 520 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -230,10 +249,17 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
files: Array<{
|
||||
path: string
|
||||
offset?: number
|
||||
mode?: string
|
||||
indentation?: { anchorLine?: number }
|
||||
}>
|
||||
}
|
||||
expect(nativeArgs.files[0].path).toBe("finalized.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([{ start: 500, end: 600 }])
|
||||
expect(nativeArgs.files[0].offset).toBe(500)
|
||||
expect(nativeArgs.files[0].mode).toBe("indentation")
|
||||
expect(nativeArgs.files[0].indentation?.anchorLine).toBe(520)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import fetchInstructions from "./fetch_instructions"
|
|||
import generateImage from "./generate_image"
|
||||
import listFiles from "./list_files"
|
||||
import newTask from "./new_task"
|
||||
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
|
||||
import { createReadFileTool, type CreateReadFileToolOptions } from "./read_file"
|
||||
import runSlashCommand from "./run_slash_command"
|
||||
import searchAndReplace from "./search_and_replace"
|
||||
import searchReplace from "./search_replace"
|
||||
|
|
@ -23,14 +23,16 @@ import writeToFile from "./write_to_file"
|
|||
|
||||
export { getMcpServerTools } from "./mcp_server"
|
||||
export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters"
|
||||
export type { ReadFileToolOptions } from "./read_file"
|
||||
export type { CreateReadFileToolOptions } from "./read_file"
|
||||
|
||||
/**
|
||||
* Options for customizing the native tools array.
|
||||
* Options for getting native tools
|
||||
*/
|
||||
export interface NativeToolsOptions {
|
||||
/** Whether to include line_ranges support in read_file tool (default: true) */
|
||||
export interface GetNativeToolsOptions {
|
||||
/** Whether to include advanced reading parameters (offset, mode, indentation) in read_file tool */
|
||||
partialReadsEnabled?: boolean
|
||||
/** The configured max lines per read (shown in description for model awareness) */
|
||||
maxReadFileLine?: number
|
||||
/** Maximum number of files that can be read in a single read_file request (default: 5) */
|
||||
maxConcurrentFileReads?: number
|
||||
/** Whether the model supports image processing (default: false) */
|
||||
|
|
@ -43,11 +45,17 @@ export interface NativeToolsOptions {
|
|||
* @param options - Configuration options for the tools
|
||||
* @returns Array of native tool definitions
|
||||
*/
|
||||
export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] {
|
||||
const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options
|
||||
export function getNativeTools(options: GetNativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] {
|
||||
const {
|
||||
partialReadsEnabled = true,
|
||||
maxReadFileLine,
|
||||
maxConcurrentFileReads = 5,
|
||||
supportsImages = false,
|
||||
} = options
|
||||
|
||||
const readFileOptions: ReadFileToolOptions = {
|
||||
const readFileOptions: CreateReadFileToolOptions = {
|
||||
partialReadsEnabled,
|
||||
maxReadFileLine,
|
||||
maxConcurrentFileReads,
|
||||
supportsImages,
|
||||
}
|
||||
|
|
@ -77,5 +85,5 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
|
|||
] satisfies OpenAI.Chat.ChatCompletionTool[]
|
||||
}
|
||||
|
||||
// Backward compatibility: export default tools with line ranges enabled
|
||||
// Backward compatibility: export default tools with advanced reading enabled
|
||||
export const nativeTools = getNativeTools()
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ function getReadFileSupportsNote(supportsImages: boolean): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Options for creating the read_file tool definition.
|
||||
* Options for creating the read_file tool definition
|
||||
*/
|
||||
export interface ReadFileToolOptions {
|
||||
/** Whether to include line_ranges parameter (default: true) */
|
||||
export interface CreateReadFileToolOptions {
|
||||
/** Whether to include advanced reading parameters (offset, mode, indentation) */
|
||||
partialReadsEnabled?: boolean
|
||||
/** The configured max lines per read (shown in description for model awareness) */
|
||||
maxReadFileLine?: number
|
||||
/** Maximum number of files that can be read in a single request (default: 5) */
|
||||
maxConcurrentFileReads?: number
|
||||
/** Whether the model supports image processing (default: false) */
|
||||
|
|
@ -26,16 +28,23 @@ export interface ReadFileToolOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Creates the read_file tool definition, optionally including line_ranges support
|
||||
* based on whether partial reads are enabled.
|
||||
* Creates the read_file tool definition with advanced reading modes.
|
||||
*
|
||||
* @param options - Configuration options for the tool
|
||||
* @returns Native tool definition for read_file
|
||||
*/
|
||||
export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool {
|
||||
const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options
|
||||
export function createReadFileTool(options: CreateReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool {
|
||||
const {
|
||||
partialReadsEnabled = true,
|
||||
maxReadFileLine,
|
||||
maxConcurrentFileReads = 5,
|
||||
supportsImages = false,
|
||||
} = options
|
||||
const isMultipleReadsEnabled = maxConcurrentFileReads > 1
|
||||
|
||||
// Build limit info for descriptions
|
||||
const limitInfo = maxReadFileLine && maxReadFileLine > 0 ? `Each read returns up to ${maxReadFileLine} lines. ` : ""
|
||||
|
||||
// Build description intro with concurrent reads limit message
|
||||
const descriptionIntro = isMultipleReadsEnabled
|
||||
? `Read one or more files and return their contents with line numbers for diffing or discussion. IMPORTANT: You can read a maximum of ${maxConcurrentFileReads} files in a single request. If you need to read more files, use multiple sequential read_file requests. `
|
||||
|
|
@ -44,19 +53,23 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch
|
|||
const baseDescription =
|
||||
descriptionIntro +
|
||||
"Structure: { files: [{ path: 'relative/path.ts'" +
|
||||
(partialReadsEnabled ? ", line_ranges: [[1, 50], [100, 150]]" : "") +
|
||||
" }] }. " +
|
||||
"The 'path' is required and relative to workspace. "
|
||||
(partialReadsEnabled ? ", offset: 1, mode: 'slice' }" : "}") +
|
||||
"] }. " +
|
||||
"The 'path' is required and relative to workspace. " +
|
||||
limitInfo
|
||||
|
||||
const optionalRangesDescription = partialReadsEnabled
|
||||
? "The 'line_ranges' is optional for reading specific sections. Each range is a [start, end] tuple (1-based inclusive). "
|
||||
const modeDescription = partialReadsEnabled
|
||||
? "Two modes available: 'slice' (default) for simple line reading with offset, " +
|
||||
"'indentation' for smart code block extraction that expands from an anchor line based on indentation levels. " +
|
||||
"Use 'offset' to paginate through large files. "
|
||||
: ""
|
||||
|
||||
const examples = partialReadsEnabled
|
||||
? "Example single file: { files: [{ path: 'src/app.ts' }] }. " +
|
||||
"Example with line ranges: { files: [{ path: 'src/app.ts', line_ranges: [[1, 50], [100, 150]] }] }. " +
|
||||
? "Example simple read: { files: [{ path: 'src/app.ts', offset: 1 }] }. " +
|
||||
"Example reading from line 500: { files: [{ path: 'src/app.ts', offset: 500 }] }. " +
|
||||
"Example indentation mode: { files: [{ path: 'src/app.ts', offset: 50, mode: 'indentation', indentation: { maxLevels: 2 } }] }. " +
|
||||
(isMultipleReadsEnabled
|
||||
? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }`
|
||||
? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', offset: 1 }, { path: 'file2.ts' }] }`
|
||||
: "")
|
||||
: "Example single file: { files: [{ path: 'src/app.ts' }] }. " +
|
||||
(isMultipleReadsEnabled
|
||||
|
|
@ -64,34 +77,72 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch
|
|||
: "")
|
||||
|
||||
const description =
|
||||
baseDescription + optionalRangesDescription + getReadFileSupportsNote(supportsImages) + " " + examples
|
||||
baseDescription + modeDescription + getReadFileSupportsNote(supportsImages) + " " + examples
|
||||
|
||||
// Build the properties object conditionally
|
||||
const fileProperties: Record<string, any> = {
|
||||
// Build the file properties object conditionally
|
||||
const fileProperties: Record<string, unknown> = {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Path to the file to read, relative to the workspace",
|
||||
},
|
||||
}
|
||||
|
||||
// Only include line_ranges if partial reads are enabled
|
||||
// Only include advanced reading parameters if partial reads are enabled
|
||||
if (partialReadsEnabled) {
|
||||
fileProperties.line_ranges = {
|
||||
type: ["array", "null"],
|
||||
const offsetDesc =
|
||||
maxReadFileLine && maxReadFileLine > 0
|
||||
? `1-indexed line number to start reading from. Use this to paginate through large files (each read returns up to ${maxReadFileLine} lines). Defaults to 1.`
|
||||
: "1-indexed line number to start reading from. Use this to paginate through large files. Defaults to 1."
|
||||
|
||||
fileProperties.offset = {
|
||||
type: ["integer", "null"],
|
||||
description: offsetDesc,
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
}
|
||||
|
||||
fileProperties.mode = {
|
||||
type: ["string", "null"],
|
||||
enum: ["slice", "indentation", null],
|
||||
description:
|
||||
"Optional line ranges to read. Each range is a [start, end] tuple with 1-based inclusive line numbers. Use multiple ranges for non-contiguous sections.",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
"Reading mode: 'slice' for simple line reading (default), 'indentation' for smart code block extraction.",
|
||||
default: "slice",
|
||||
}
|
||||
|
||||
fileProperties.indentation = {
|
||||
type: ["object", "null"],
|
||||
description: "Configuration for indentation mode. Only used when mode is 'indentation'.",
|
||||
properties: {
|
||||
anchorLine: {
|
||||
type: ["integer", "null"],
|
||||
description: "The line to anchor the block expansion from. Defaults to offset.",
|
||||
minimum: 1,
|
||||
},
|
||||
maxLevels: {
|
||||
type: ["integer", "null"],
|
||||
description:
|
||||
"Maximum indentation depth to collect. 0 = unlimited (expand to file-level). Defaults to 0.",
|
||||
default: 0,
|
||||
minimum: 0,
|
||||
},
|
||||
includeSiblings: {
|
||||
type: ["boolean", "null"],
|
||||
description: "Whether to include sibling blocks at the same indentation level. Defaults to false.",
|
||||
default: false,
|
||||
},
|
||||
includeHeader: {
|
||||
type: ["boolean", "null"],
|
||||
description: "Whether to include comment headers above the anchor block. Defaults to true.",
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
}
|
||||
|
||||
// When using strict mode, ALL properties must be in the required array
|
||||
// Optional properties are handled by having type: ["...", "null"]
|
||||
const fileRequiredProperties = partialReadsEnabled ? ["path", "line_ranges"] : ["path"]
|
||||
const fileRequiredProperties = partialReadsEnabled ? ["path", "offset", "mode", "indentation"] : ["path"]
|
||||
|
||||
return {
|
||||
type: "function",
|
||||
|
|
|
|||
|
|
@ -119,8 +119,10 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
|
|||
const supportsImages = modelInfo?.supportsImages ?? false
|
||||
|
||||
// Build native tools with dynamic read_file tool based on settings.
|
||||
// Pass maxReadFileLine so the tool description tells the model about the limit.
|
||||
const nativeTools = getNativeTools({
|
||||
partialReadsEnabled,
|
||||
maxReadFileLine: partialReadsEnabled ? maxReadFileLine : undefined,
|
||||
maxConcurrentFileReads,
|
||||
supportsImages,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
|
||||
import type { FileEntry, LineRange } from "@roo-code/types"
|
||||
import type { FileEntry, ReadMode, IndentationConfig } from "@roo-code/types"
|
||||
import { type ClineSayTool, isNativeProtocol, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
|
||||
|
||||
import { Task } from "../task/Task"
|
||||
|
|
@ -13,9 +12,8 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
|||
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
|
||||
import { getReadablePath } from "../../utils/path"
|
||||
import { countFileLines } from "../../integrations/misc/line-counter"
|
||||
import { readLines } from "../../integrations/misc/read-lines"
|
||||
import { readFileContent } from "../../integrations/misc/read-file-content"
|
||||
import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
|
||||
import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
|
||||
import { parseXml } from "../../utils/xml"
|
||||
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
|
||||
import type { ToolUse } from "../../shared/tools"
|
||||
|
|
@ -29,7 +27,6 @@ import {
|
|||
ImageMemoryTracker,
|
||||
} from "./helpers/imageHelpers"
|
||||
import { FILE_READ_BUDGET_PERCENT, readFileWithTokenBudget } from "./helpers/fileTokenBudget"
|
||||
import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions"
|
||||
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
||||
|
||||
interface FileResult {
|
||||
|
|
@ -38,7 +35,11 @@ interface FileResult {
|
|||
content?: string
|
||||
error?: string
|
||||
notice?: string
|
||||
lineRanges?: LineRange[]
|
||||
// Slice/indentation mode parameters
|
||||
offset?: number
|
||||
limit?: number
|
||||
mode?: ReadMode
|
||||
indentation?: IndentationConfig
|
||||
xmlContent?: string
|
||||
nativeContent?: string
|
||||
imageDataUrl?: string
|
||||
|
|
@ -51,65 +52,27 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
|
||||
parseLegacy(params: Partial<Record<string, string>>): { files: FileEntry[] } {
|
||||
const argsXmlTag = params.args
|
||||
const legacyPath = params.path
|
||||
const legacyStartLineStr = params.start_line
|
||||
const legacyEndLineStr = params.end_line
|
||||
|
||||
const fileEntries: FileEntry[] = []
|
||||
|
||||
// XML args format
|
||||
// XML args format - just parse paths, advanced features are native-only
|
||||
if (argsXmlTag) {
|
||||
const parsed = parseXml(argsXmlTag) as any
|
||||
const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.path) continue
|
||||
|
||||
const fileEntry: FileEntry = {
|
||||
path: file.path,
|
||||
lineRanges: [],
|
||||
}
|
||||
|
||||
if (file.line_range) {
|
||||
const ranges = Array.isArray(file.line_range) ? file.line_range : [file.line_range]
|
||||
for (const range of ranges) {
|
||||
const match = String(range).match(/(\d+)-(\d+)/)
|
||||
if (match) {
|
||||
const [, start, end] = match.map(Number)
|
||||
if (!isNaN(start) && !isNaN(end)) {
|
||||
fileEntry.lineRanges?.push({ start, end })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fileEntries.push(fileEntry)
|
||||
fileEntries.push({ path: file.path })
|
||||
}
|
||||
|
||||
return { files: fileEntries }
|
||||
}
|
||||
|
||||
// Legacy single file path
|
||||
if (legacyPath) {
|
||||
const fileEntry: FileEntry = {
|
||||
path: legacyPath,
|
||||
lineRanges: [],
|
||||
}
|
||||
|
||||
if (legacyStartLineStr && legacyEndLineStr) {
|
||||
const start = parseInt(legacyStartLineStr, 10)
|
||||
const end = parseInt(legacyEndLineStr, 10)
|
||||
if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0) {
|
||||
fileEntry.lineRanges?.push({ start, end })
|
||||
}
|
||||
}
|
||||
fileEntries.push(fileEntry)
|
||||
}
|
||||
|
||||
return { files: fileEntries }
|
||||
}
|
||||
|
||||
async execute(params: { files: FileEntry[] }, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
const { handleError, pushToolResult, toolProtocol } = callbacks
|
||||
const { pushToolResult } = callbacks
|
||||
const fileEntries = params.files
|
||||
const modelInfo = task.api.getModel().info
|
||||
// Use the task's locked protocol for consistent output formatting throughout the task
|
||||
|
|
@ -142,7 +105,10 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
const fileResults: FileResult[] = fileEntries.map((entry) => ({
|
||||
path: entry.path,
|
||||
status: "pending",
|
||||
lineRanges: entry.lineRanges,
|
||||
// Map slice/indentation mode parameters (limit is not mapped - always use maxReadFileLine setting)
|
||||
offset: entry.offset,
|
||||
mode: entry.mode,
|
||||
indentation: entry.indentation,
|
||||
}))
|
||||
|
||||
const updateFileResult = (filePath: string, updates: Partial<FileResult>) => {
|
||||
|
|
@ -157,38 +123,6 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
|
||||
for (const fileResult of fileResults) {
|
||||
const relPath = fileResult.path
|
||||
const fullPath = path.resolve(task.cwd, relPath)
|
||||
|
||||
if (fileResult.lineRanges) {
|
||||
let hasRangeError = false
|
||||
for (const range of fileResult.lineRanges) {
|
||||
if (range.start > range.end) {
|
||||
const errorMsg = "Invalid line range: end line cannot be less than start line"
|
||||
updateFileResult(relPath, {
|
||||
status: "blocked",
|
||||
error: errorMsg,
|
||||
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
|
||||
nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
|
||||
})
|
||||
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
|
||||
hasRangeError = true
|
||||
break
|
||||
}
|
||||
if (isNaN(range.start) || isNaN(range.end)) {
|
||||
const errorMsg = "Invalid line range values"
|
||||
updateFileResult(relPath, {
|
||||
status: "blocked",
|
||||
error: errorMsg,
|
||||
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
|
||||
nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
|
||||
})
|
||||
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
|
||||
hasRangeError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasRangeError) continue
|
||||
}
|
||||
|
||||
if (fileResult.status === "pending") {
|
||||
const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
|
||||
|
|
@ -217,15 +151,14 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
|
||||
|
||||
let lineSnippet = ""
|
||||
if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
|
||||
const ranges = fileResult.lineRanges.map((range) =>
|
||||
t("tools:readFile.linesRange", { start: range.start, end: range.end }),
|
||||
)
|
||||
lineSnippet = ranges.join(", ")
|
||||
} else if (maxReadFileLine === 0) {
|
||||
lineSnippet = t("tools:readFile.definitionsOnly")
|
||||
} else if (maxReadFileLine > 0) {
|
||||
lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine })
|
||||
const startLine = fileResult.offset ?? 1
|
||||
if (maxReadFileLine > 0) {
|
||||
// Show the expected line range (start to start + maxReadFileLine - 1)
|
||||
const endLine = startLine + maxReadFileLine - 1
|
||||
lineSnippet = t("tools:readFile.linesRange", { start: startLine, end: endLine })
|
||||
} else if (startLine > 1) {
|
||||
// No line limit but reading from offset
|
||||
lineSnippet = t("tools:readFile.startingFromLine", { start: startLine })
|
||||
}
|
||||
|
||||
const readablePath = getReadablePath(task.cwd, relPath)
|
||||
|
|
@ -300,15 +233,14 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {}
|
||||
|
||||
let lineSnippet = ""
|
||||
if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
|
||||
const ranges = fileResult.lineRanges.map((range) =>
|
||||
t("tools:readFile.linesRange", { start: range.start, end: range.end }),
|
||||
)
|
||||
lineSnippet = ranges.join(", ")
|
||||
} else if (maxReadFileLine === 0) {
|
||||
lineSnippet = t("tools:readFile.definitionsOnly")
|
||||
} else if (maxReadFileLine > 0) {
|
||||
lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine })
|
||||
const startLine = fileResult.offset ?? 1
|
||||
if (maxReadFileLine > 0) {
|
||||
// Show the expected line range (start to start + maxReadFileLine - 1)
|
||||
const endLine = startLine + maxReadFileLine - 1
|
||||
lineSnippet = t("tools:readFile.linesRange", { start: startLine, end: endLine })
|
||||
} else if (startLine > 1) {
|
||||
// No line limit but reading from offset
|
||||
lineSnippet = t("tools:readFile.startingFromLine", { start: startLine })
|
||||
}
|
||||
|
||||
const completeMessage = JSON.stringify({
|
||||
|
|
@ -458,86 +390,106 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
}
|
||||
}
|
||||
|
||||
if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
|
||||
const rangeResults: string[] = []
|
||||
const nativeRangeResults: string[] = []
|
||||
|
||||
for (const range of fileResult.lineRanges) {
|
||||
const content = addLineNumbers(
|
||||
await readLines(fullPath, range.end - 1, range.start - 1),
|
||||
range.start,
|
||||
)
|
||||
const lineRangeAttr = ` lines="${range.start}-${range.end}"`
|
||||
rangeResults.push(`<content${lineRangeAttr}>\n${content}</content>`)
|
||||
nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${content}`)
|
||||
}
|
||||
|
||||
updateFileResult(relPath, {
|
||||
xmlContent: `<file><path>${relPath}</path>\n${rangeResults.join("\n")}\n</file>`,
|
||||
nativeContent: `File: ${relPath}\n${nativeRangeResults.join("\n\n")}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (maxReadFileLine === 0) {
|
||||
// Handle slice/indentation mode when offset or mode is specified
|
||||
if (fileResult.offset !== undefined || fileResult.mode !== undefined) {
|
||||
try {
|
||||
const defResult = await parseSourceCodeDefinitionsForFile(
|
||||
fullPath,
|
||||
task.rooIgnoreController,
|
||||
)
|
||||
if (defResult) {
|
||||
const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines`
|
||||
updateFileResult(relPath, {
|
||||
xmlContent: `<file><path>${relPath}</path>\n<list_code_definition_names>${defResult}</list_code_definition_names>\n<notice>${notice}</notice>\n</file>`,
|
||||
nativeContent: `File: ${relPath}\nCode Definitions:\n${defResult}\n\nNote: ${notice}`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unsupported language:")) {
|
||||
console.warn(`[read_file] Warning: ${error.message}`)
|
||||
const result = await readFileContent({
|
||||
filePath: fullPath,
|
||||
offset: fileResult.offset,
|
||||
// limit is controlled by maxReadFileLine setting, not model input
|
||||
mode: fileResult.mode,
|
||||
indentation: fileResult.indentation,
|
||||
defaultLimit: maxReadFileLine > 0 ? maxReadFileLine : undefined,
|
||||
})
|
||||
|
||||
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
|
||||
|
||||
const modeLabel = fileResult.mode === "indentation" ? "indentation" : "slice"
|
||||
const { metadata } = result
|
||||
let xmlInfo = ""
|
||||
let nativeInfo = ""
|
||||
|
||||
if (result.lineCount === 0) {
|
||||
xmlInfo = `<content/>\n<notice>No content returned (file may be empty or offset exceeds file length)</notice>\n`
|
||||
nativeInfo = `Note: No content returned (file may be empty or offset exceeds file length)`
|
||||
} else {
|
||||
console.error(
|
||||
`[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
const lineRangeAttr = ` lines="${metadata.startLine}-${metadata.endLine}"`
|
||||
xmlInfo = `<content mode="${modeLabel}"${lineRangeAttr}>\n${result.content}</content>\n`
|
||||
nativeInfo = `Lines ${metadata.startLine}-${metadata.endLine} (${modeLabel} mode):\n${result.content}`
|
||||
|
||||
// Include structured metadata for LLM pagination awareness
|
||||
const metadataJson = JSON.stringify({
|
||||
totalLinesInFile: metadata.totalLinesInFile,
|
||||
linesReturned: metadata.linesReturned,
|
||||
startLine: metadata.startLine,
|
||||
endLine: metadata.endLine,
|
||||
hasMoreBefore: metadata.hasMoreBefore,
|
||||
hasMoreAfter: metadata.hasMoreAfter,
|
||||
linesBeforeStart: metadata.linesBeforeStart,
|
||||
linesAfterEnd: metadata.linesAfterEnd,
|
||||
truncatedByLimit: metadata.truncatedByLimit,
|
||||
lineLengthTruncations: metadata.lineLengthTruncations,
|
||||
})
|
||||
xmlInfo += `<metadata>${metadataJson}</metadata>\n`
|
||||
nativeInfo += `\n\n<metadata>${metadataJson}</metadata>`
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (maxReadFileLine > 0 && totalLines > maxReadFileLine) {
|
||||
const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0))
|
||||
const lineRangeAttr = ` lines="1-${maxReadFileLine}"`
|
||||
let xmlInfo = `<content${lineRangeAttr}>\n${content}</content>\n`
|
||||
let nativeInfo = `Lines 1-${maxReadFileLine}:\n${content}\n`
|
||||
|
||||
try {
|
||||
const defResult = await parseSourceCodeDefinitionsForFile(
|
||||
fullPath,
|
||||
task.rooIgnoreController,
|
||||
)
|
||||
if (defResult) {
|
||||
const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine)
|
||||
xmlInfo += `<list_code_definition_names>${truncatedDefs}</list_code_definition_names>\n`
|
||||
nativeInfo += `\nCode Definitions:\n${truncatedDefs}\n`
|
||||
}
|
||||
|
||||
const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines`
|
||||
xmlInfo += `<notice>${notice}</notice>\n`
|
||||
nativeInfo += `\nNote: ${notice}`
|
||||
|
||||
updateFileResult(relPath, {
|
||||
xmlContent: `<file><path>${relPath}</path>\n${xmlInfo}</file>`,
|
||||
nativeContent: `File: ${relPath}\n${nativeInfo}`,
|
||||
})
|
||||
continue
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unsupported language:")) {
|
||||
console.warn(`[read_file] Warning: ${error.message}`)
|
||||
} else {
|
||||
console.error(
|
||||
`[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
updateFileResult(relPath, {
|
||||
status: "error",
|
||||
error: `Error reading file with ${fileResult.mode || "slice"} mode: ${errorMsg}`,
|
||||
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
|
||||
nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
|
||||
})
|
||||
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle maxReadFileLine partial read
|
||||
if (maxReadFileLine > 0 && totalLines > maxReadFileLine) {
|
||||
const sliceResult = await readFileContent({
|
||||
filePath: fullPath,
|
||||
offset: 1,
|
||||
limit: maxReadFileLine,
|
||||
mode: "slice",
|
||||
})
|
||||
// readFileContent already includes line numbers
|
||||
const content = sliceResult.content
|
||||
const { metadata } = sliceResult
|
||||
|
||||
// Build metadata summary for pagination awareness
|
||||
const metadataSummary = []
|
||||
if (metadata.hasMoreAfter) {
|
||||
metadataSummary.push(`${metadata.linesAfterEnd} lines after`)
|
||||
}
|
||||
if (metadata.lineLengthTruncations.length > 0) {
|
||||
metadataSummary.push(`${metadata.lineLengthTruncations.length} lines truncated for length`)
|
||||
}
|
||||
|
||||
const lineRangeAttr = ` lines="${metadata.startLine}-${metadata.endLine}"`
|
||||
let xmlInfo = `<content${lineRangeAttr}>\n${content}</content>\n`
|
||||
let nativeInfo = `Lines ${metadata.startLine}-${metadata.endLine}:\n${content}\n`
|
||||
|
||||
const notice =
|
||||
`Showing ${sliceResult.lineCount} of ${metadata.totalLinesInFile} total lines` +
|
||||
(metadataSummary.length > 0 ? ` (${metadataSummary.join(", ")})` : "") +
|
||||
`. Use offset to read more`
|
||||
xmlInfo += `<notice>${notice}</notice>\n`
|
||||
nativeInfo += `\nNote: ${notice}`
|
||||
|
||||
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
|
||||
|
||||
updateFileResult(relPath, {
|
||||
xmlContent: `<file><path>${relPath}</path>\n${xmlInfo}</file>`,
|
||||
nativeContent: `File: ${relPath}\n${nativeInfo}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -576,7 +528,7 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
|
||||
if (!result.complete) {
|
||||
// File was truncated
|
||||
const notice = `File truncated: showing ${result.lineCount} lines (${result.tokenCount} tokens) due to context budget. Use line_range to read specific sections.`
|
||||
const notice = `File truncated: showing ${result.lineCount} lines (${result.tokenCount} tokens) due to context budget. Use offset to read more.`
|
||||
const lineRangeAttr = result.lineCount > 0 ? ` lines="1-${result.lineCount}"` : ""
|
||||
xmlInfo =
|
||||
result.lineCount > 0
|
||||
|
|
@ -744,7 +696,7 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy/XML or synthesized params
|
||||
// XML args format
|
||||
const blockParams = second as any
|
||||
|
||||
if (blockParams?.args) {
|
||||
|
|
@ -767,44 +719,19 @@ export class ReadFileTool extends BaseTool<"read_file"> {
|
|||
console.error("Failed to parse read_file args XML for description:", error)
|
||||
return `[${blockName} with unparsable args]`
|
||||
}
|
||||
} else if (blockParams?.path) {
|
||||
return `[${blockName} for '${blockParams.path}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]`
|
||||
} else if (blockParams?.files) {
|
||||
// Back-compat: some paths may still synthesize params.files; try to parse if present
|
||||
try {
|
||||
const files = JSON.parse(blockParams.files)
|
||||
if (Array.isArray(files) && files.length > 0) {
|
||||
const paths = files.map((f: any) => f?.path).filter(Boolean) as string[]
|
||||
if (paths.length === 1) {
|
||||
return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]`
|
||||
} else if (paths.length <= 3) {
|
||||
const pathList = paths.map((p) => `'${p}'`).join(", ")
|
||||
return `[${blockName} for ${pathList}]`
|
||||
} else {
|
||||
return `[${blockName} for ${paths.length} files]`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse native files JSON for description:", error)
|
||||
return `[${blockName} with unparsable files]`
|
||||
}
|
||||
}
|
||||
|
||||
return `[${blockName} with missing path/args/files]`
|
||||
return `[${blockName} with missing args]`
|
||||
}
|
||||
|
||||
override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise<void> {
|
||||
const argsXmlTag = block.params.args
|
||||
const legacyPath = block.params.path
|
||||
|
||||
let filePath = ""
|
||||
if (argsXmlTag) {
|
||||
const match = argsXmlTag.match(/<file>.*?<path>([^<]+)<\/path>/s)
|
||||
if (match) filePath = match[1]
|
||||
}
|
||||
if (!filePath && legacyPath) {
|
||||
filePath = legacyPath
|
||||
}
|
||||
|
||||
if (!filePath && block.nativeArgs && "files" in block.nativeArgs && Array.isArray(block.nativeArgs.files)) {
|
||||
const files = block.nativeArgs.files
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@
|
|||
import * as path from "path"
|
||||
|
||||
import { countFileLines } from "../../../integrations/misc/line-counter"
|
||||
import { readLines } from "../../../integrations/misc/read-lines"
|
||||
import { extractTextFromFile } from "../../../integrations/misc/extract-text"
|
||||
import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools"
|
||||
import { readFileTool } from "../ReadFileTool"
|
||||
|
|
@ -24,7 +22,13 @@ vi.mock("path", async () => {
|
|||
vi.mock("isbinaryfile")
|
||||
|
||||
vi.mock("../../../integrations/misc/line-counter")
|
||||
vi.mock("../../../integrations/misc/read-lines")
|
||||
|
||||
// Create hoisted mock for readFileContent
|
||||
const mockReadFileContent = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock("../../../integrations/misc/read-file-content", () => ({
|
||||
readFileContent: mockReadFileContent,
|
||||
}))
|
||||
|
||||
// Mock fs/promises readFile for image tests
|
||||
const fsPromises = vi.hoisted(() => ({
|
||||
|
|
@ -53,7 +57,6 @@ vi.mock("../../../integrations/misc/extract-text", () => ({
|
|||
addLineNumbers: addLineNumbersMock,
|
||||
getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]),
|
||||
}))
|
||||
vi.mock("../../../services/tree-sitter")
|
||||
|
||||
// Mock readFileWithTokenBudget - must be mocked to prevent actual file system access
|
||||
vi.mock("../../../integrations/misc/read-file-with-budget", () => ({
|
||||
|
|
@ -269,13 +272,10 @@ describe("read_file tool with maxReadFileLine setting", () => {
|
|||
const absoluteFilePath = "/test/file.txt"
|
||||
const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
||||
const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n"
|
||||
const sourceCodeDef = "\n\n# file.txt\n1--5 | Content"
|
||||
|
||||
// Mocked functions with correct types
|
||||
const mockedCountFileLines = vi.mocked(countFileLines)
|
||||
const mockedReadLines = vi.mocked(readLines)
|
||||
const mockedExtractTextFromFile = vi.mocked(extractTextFromFile)
|
||||
const mockedParseSourceCodeDefinitionsForFile = vi.mocked(parseSourceCodeDefinitionsForFile)
|
||||
|
||||
const mockedIsBinaryFile = vi.mocked(isBinaryFile)
|
||||
const mockedPathResolve = vi.mocked(path.resolve)
|
||||
|
|
@ -410,106 +410,73 @@ describe("read_file tool with maxReadFileLine setting", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("when maxReadFileLine is 0", () => {
|
||||
it("should return an empty content with source code definitions", async () => {
|
||||
// Setup - for maxReadFileLine = 0, the implementation won't call readLines
|
||||
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
|
||||
|
||||
// Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0
|
||||
const result = await executeReadFileTool(
|
||||
{},
|
||||
{
|
||||
maxReadFileLine: 0,
|
||||
totalLines: 5,
|
||||
skipAddLineNumbersCheck: true,
|
||||
},
|
||||
)
|
||||
|
||||
// Verify - native format
|
||||
expect(result).toContain(`File: ${testFilePath}`)
|
||||
expect(result).toContain(`Code Definitions:`)
|
||||
|
||||
// Verify native structure
|
||||
expect(result).toContain("Note: Showing only 0 of 5 total lines")
|
||||
expect(result).toContain(sourceCodeDef.trim())
|
||||
expect(result).not.toContain("Lines 1-") // No content when maxReadFileLine is 0
|
||||
})
|
||||
})
|
||||
|
||||
describe("when maxReadFileLine is less than file length", () => {
|
||||
it("should read only maxReadFileLine lines and add source code definitions", async () => {
|
||||
it("should read only maxReadFileLine lines with notice", async () => {
|
||||
// Setup
|
||||
const content = "Line 1\nLine 2\nLine 3"
|
||||
const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3"
|
||||
mockedReadLines.mockResolvedValue(content)
|
||||
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
|
||||
|
||||
// Setup addLineNumbers to always return numbered content
|
||||
addLineNumbersMock.mockReturnValue(numberedContent)
|
||||
// Mock readFileContent for partial read with metadata
|
||||
mockReadFileContent.mockResolvedValue({
|
||||
content: numberedContent,
|
||||
lineCount: 3,
|
||||
totalLines: 5,
|
||||
metadata: {
|
||||
filePath: absoluteFilePath,
|
||||
totalLinesInFile: 5,
|
||||
linesReturned: 3,
|
||||
startLine: 1,
|
||||
endLine: 3,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: true,
|
||||
linesBeforeStart: 0,
|
||||
linesAfterEnd: 2,
|
||||
truncatedByLimit: true,
|
||||
lineLengthTruncations: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Execute
|
||||
const result = await executeReadFileTool({}, { maxReadFileLine: 3 })
|
||||
|
||||
// Verify - native format
|
||||
expect(result).toContain(`File: ${testFilePath}`)
|
||||
expect(result).toContain(`Lines 1-3:`)
|
||||
expect(result).toContain(`Code Definitions:`)
|
||||
expect(result).toContain("Note: Showing only 3 of 5 total lines")
|
||||
// Verify - just check that the result contains the expected elements
|
||||
expect(result).toContain(`<file><path>${testFilePath}</path>`)
|
||||
expect(result).toContain(`<content lines="1-3">`)
|
||||
expect(result).toContain("<notice>Showing 3 of 5 total lines")
|
||||
})
|
||||
|
||||
it("should truncate code definitions when file exceeds maxReadFileLine", async () => {
|
||||
// Setup - file with 100 lines but we'll only read first 30
|
||||
it("should suggest using offset/limit for specific sections", async () => {
|
||||
// Setup
|
||||
const content = "Line 1\nLine 2\nLine 3"
|
||||
const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3"
|
||||
const fullDefinitions = `# file.txt
|
||||
10--20 | function foo() {
|
||||
50--60 | function bar() {
|
||||
80--90 | function baz() {`
|
||||
const truncatedDefinitions = `# file.txt
|
||||
10--20 | function foo() {`
|
||||
|
||||
mockedReadLines.mockResolvedValue(content)
|
||||
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(fullDefinitions)
|
||||
addLineNumbersMock.mockReturnValue(numberedContent)
|
||||
// Mock readFileContent for partial read with metadata
|
||||
mockReadFileContent.mockResolvedValue({
|
||||
content: numberedContent,
|
||||
lineCount: 3,
|
||||
totalLines: 100,
|
||||
metadata: {
|
||||
filePath: absoluteFilePath,
|
||||
totalLinesInFile: 100,
|
||||
linesReturned: 3,
|
||||
startLine: 1,
|
||||
endLine: 3,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: true,
|
||||
linesBeforeStart: 0,
|
||||
linesAfterEnd: 97,
|
||||
truncatedByLimit: true,
|
||||
lineLengthTruncations: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Execute with maxReadFileLine = 30
|
||||
const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 })
|
||||
// Execute with maxReadFileLine = 3
|
||||
const result = await executeReadFileTool({}, { maxReadFileLine: 3, totalLines: 100 })
|
||||
|
||||
// Verify - native format
|
||||
expect(result).toContain(`File: ${testFilePath}`)
|
||||
expect(result).toContain(`Lines 1-30:`)
|
||||
expect(result).toContain(`Code Definitions:`)
|
||||
|
||||
// Should include foo (starts at line 10) but not bar (starts at line 50) or baz (starts at line 80)
|
||||
expect(result).toContain("10--20 | function foo()")
|
||||
expect(result).not.toContain("50--60 | function bar()")
|
||||
expect(result).not.toContain("80--90 | function baz()")
|
||||
|
||||
expect(result).toContain("Note: Showing only 30 of 100 total lines")
|
||||
})
|
||||
|
||||
it("should handle truncation when all definitions are beyond the line limit", async () => {
|
||||
// Setup - all definitions start after maxReadFileLine
|
||||
const content = "Line 1\nLine 2\nLine 3"
|
||||
const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3"
|
||||
const fullDefinitions = `# file.txt
|
||||
50--60 | function foo() {
|
||||
80--90 | function bar() {`
|
||||
|
||||
mockedReadLines.mockResolvedValue(content)
|
||||
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(fullDefinitions)
|
||||
addLineNumbersMock.mockReturnValue(numberedContent)
|
||||
|
||||
// Execute with maxReadFileLine = 30
|
||||
const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 })
|
||||
|
||||
// Verify - native format
|
||||
expect(result).toContain(`File: ${testFilePath}`)
|
||||
expect(result).toContain(`Lines 1-30:`)
|
||||
expect(result).toContain(`Code Definitions:`)
|
||||
expect(result).toContain("# file.txt")
|
||||
expect(result).not.toContain("50--60 | function foo()")
|
||||
expect(result).not.toContain("80--90 | function bar()")
|
||||
// Verify notice includes suggestion about offset/limit
|
||||
expect(result).toContain(`<file><path>${testFilePath}</path>`)
|
||||
expect(result).toContain(`<content lines="1-3">`)
|
||||
expect(result).toContain("Use offset to read more")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -565,26 +532,6 @@ describe("read_file tool with maxReadFileLine setting", () => {
|
|||
expect(typeof result).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with range parameters", () => {
|
||||
it("should honor start_line and end_line when provided", async () => {
|
||||
// Setup
|
||||
mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4")
|
||||
|
||||
// Execute using executeReadFileTool with range parameters
|
||||
const rangeResult = await executeReadFileTool(
|
||||
{},
|
||||
{
|
||||
start_line: "2",
|
||||
end_line: "4",
|
||||
},
|
||||
)
|
||||
|
||||
// Verify - native format
|
||||
expect(rangeResult).toContain(`File: ${testFilePath}`)
|
||||
expect(rangeResult).toContain(`Lines 2-4:`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("read_file tool output structure", () => {
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { truncateDefinitionsToLineLimit } from "../truncateDefinitions"
|
||||
|
||||
describe("truncateDefinitionsToLineLimit", () => {
|
||||
it("should not truncate when maxReadFileLine is -1 (no limit)", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, -1)
|
||||
expect(result).toBe(definitions)
|
||||
})
|
||||
|
||||
it("should not truncate when maxReadFileLine is 0 (definitions only mode)", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 0)
|
||||
expect(result).toBe(definitions)
|
||||
})
|
||||
|
||||
it("should truncate definitions beyond the line limit", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should include definitions that start within limit even if they end beyond it", () => {
|
||||
const definitions = `# test.ts
|
||||
10--50 | function foo() {
|
||||
60--80 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 30)
|
||||
const expected = `# test.ts
|
||||
10--50 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle single-line definitions", () => {
|
||||
const definitions = `# test.ts
|
||||
10 | const foo = 1
|
||||
20 | const bar = 2
|
||||
30 | const baz = 3`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10 | const foo = 1
|
||||
20 | const bar = 2`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should preserve header line when all definitions are beyond limit", () => {
|
||||
const definitions = `# test.ts
|
||||
100--200 | function foo() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 50)
|
||||
const expected = `# test.ts`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle empty definitions", () => {
|
||||
const definitions = `# test.ts`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 50)
|
||||
expect(result).toBe(definitions)
|
||||
})
|
||||
|
||||
it("should handle definitions without header", () => {
|
||||
const definitions = `10--20 | function foo() {
|
||||
30--40 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not preserve empty lines (only definition lines)", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
|
||||
30--40 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle mixed single and range definitions", () => {
|
||||
const definitions = `# test.ts
|
||||
5 | const x = 1
|
||||
10--20 | function foo() {
|
||||
25 | const y = 2
|
||||
30--40 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 26)
|
||||
const expected = `# test.ts
|
||||
5 | const x = 1
|
||||
10--20 | function foo() {
|
||||
25 | const y = 2`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle definitions at exactly the limit", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 30)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle definitions with leading whitespace", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle definitions with mixed whitespace patterns", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 35)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* Truncate code definitions to only include those within the line limit
|
||||
* @param definitions - The full definitions string from parseSourceCodeDefinitionsForFile
|
||||
* @param maxReadFileLine - Maximum line number to include (-1 for no limit, 0 for definitions only)
|
||||
* @returns Truncated definitions string
|
||||
*/
|
||||
export function truncateDefinitionsToLineLimit(definitions: string, maxReadFileLine: number): string {
|
||||
// If no limit or definitions-only mode (0), return as-is
|
||||
if (maxReadFileLine <= 0) {
|
||||
return definitions
|
||||
}
|
||||
|
||||
const lines = definitions.split("\n")
|
||||
const result: string[] = []
|
||||
let startIndex = 0
|
||||
|
||||
// Keep the header line (e.g., "# filename.ts")
|
||||
if (lines.length > 0 && lines[0].startsWith("#")) {
|
||||
result.push(lines[0])
|
||||
startIndex = 1
|
||||
}
|
||||
|
||||
// Process definition lines
|
||||
for (let i = startIndex; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Match definition format: "startLine--endLine | content" or "lineNumber | content"
|
||||
// Allow optional leading whitespace to handle indented output or CRLF artifacts
|
||||
const rangeMatch = line.match(/^\s*(\d+)(?:--(\d+))?\s*\|/)
|
||||
|
||||
if (rangeMatch) {
|
||||
const startLine = parseInt(rangeMatch[1], 10)
|
||||
|
||||
// Only include definitions that start within the truncated range
|
||||
if (startLine <= maxReadFileLine) {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
// Note: We don't preserve empty lines or other non-definition content
|
||||
// as they're not part of the actual code definitions
|
||||
}
|
||||
|
||||
return result.join("\n")
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"readFile": {
|
||||
"linesRange": " (lines {{start}}-{{end}})",
|
||||
"startingFromLine": " (starting from line {{start}})",
|
||||
"definitionsOnly": " (definitions only)",
|
||||
"maxLines": " (max {{max}} lines)",
|
||||
"imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.",
|
||||
|
|
|
|||
282
src/integrations/misc/__tests__/read-file-content.spec.ts
Normal file
282
src/integrations/misc/__tests__/read-file-content.spec.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { readSlice, readIndentationBlock, readFileContent, measureIndent } from "../read-file-content"
|
||||
|
||||
describe("read-file-content", () => {
|
||||
const testDir = __dirname
|
||||
|
||||
// Helper function to create a temporary file, run a test, and clean up
|
||||
async function withTempFile(filename: string, content: string, testFn: (filepath: string) => Promise<void>) {
|
||||
const filepath = path.join(testDir, filename)
|
||||
await fs.writeFile(filepath, content)
|
||||
try {
|
||||
await testFn(filepath)
|
||||
} finally {
|
||||
await fs.unlink(filepath)
|
||||
}
|
||||
}
|
||||
|
||||
describe("measureIndent", () => {
|
||||
it("should measure spaces correctly", () => {
|
||||
expect(measureIndent(" hello")).toBe(4)
|
||||
expect(measureIndent(" hello")).toBe(2)
|
||||
expect(measureIndent("hello")).toBe(0)
|
||||
})
|
||||
|
||||
it("should treat tabs as 4 spaces", () => {
|
||||
expect(measureIndent("\thello")).toBe(4)
|
||||
expect(measureIndent("\t\thello")).toBe(8)
|
||||
expect(measureIndent(" \thello")).toBe(6) // 2 spaces + 4 for tab
|
||||
})
|
||||
|
||||
it("should handle empty lines", () => {
|
||||
expect(measureIndent("")).toBe(0)
|
||||
expect(measureIndent(" ")).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readSlice", () => {
|
||||
it("should read lines from start by default", async () => {
|
||||
const content = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`).join("\n")
|
||||
await withTempFile("slice-test.txt", content, async (filepath) => {
|
||||
const result = await readSlice(filepath, 1, 3)
|
||||
expect(result.lineCount).toBe(3)
|
||||
expect(result.content).toContain("1 | Line 1")
|
||||
expect(result.content).toContain("2 | Line 2")
|
||||
expect(result.content).toContain("3 | Line 3")
|
||||
})
|
||||
})
|
||||
|
||||
it("should read lines from specified offset", async () => {
|
||||
const content = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`).join("\n")
|
||||
await withTempFile("slice-offset-test.txt", content, async (filepath) => {
|
||||
const result = await readSlice(filepath, 5, 3)
|
||||
expect(result.lineCount).toBe(3)
|
||||
expect(result.content).toContain("5 | Line 5")
|
||||
expect(result.content).toContain("6 | Line 6")
|
||||
expect(result.content).toContain("7 | Line 7")
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle reading beyond file end", async () => {
|
||||
const content = "Line 1\nLine 2\nLine 3"
|
||||
await withTempFile("slice-beyond-test.txt", content, async (filepath) => {
|
||||
const result = await readSlice(filepath, 1, 100)
|
||||
expect(result.lineCount).toBe(3)
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error for offset=0", async () => {
|
||||
const content = "Line 1\nLine 2"
|
||||
await withTempFile("slice-zero-offset-test.txt", content, async (filepath) => {
|
||||
await expect(readSlice(filepath, 0, 3)).rejects.toThrow("offset must be a 1-indexed line number")
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error for limit=0", async () => {
|
||||
const content = "Line 1\nLine 2"
|
||||
await withTempFile("slice-zero-limit-test.txt", content, async (filepath) => {
|
||||
await expect(readSlice(filepath, 1, 0)).rejects.toThrow("limit must be greater than zero")
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when offset exceeds file length", async () => {
|
||||
const content = "Line 1\nLine 2"
|
||||
await withTempFile("slice-past-end-test.txt", content, async (filepath) => {
|
||||
await expect(readSlice(filepath, 100, 3)).rejects.toThrow("offset exceeds file length")
|
||||
})
|
||||
})
|
||||
|
||||
it("should truncate long lines", async () => {
|
||||
const longLine = "x".repeat(600) // Longer than MAX_LINE_LENGTH (500)
|
||||
await withTempFile("slice-long-line-test.txt", longLine, async (filepath) => {
|
||||
const result = await readSlice(filepath, 1, 1)
|
||||
// Line should be truncated to 500 characters + line number prefix
|
||||
expect(result.content.length).toBeLessThan(600)
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle files with CRLF line endings", async () => {
|
||||
const content = "Line 1\r\nLine 2\r\nLine 3"
|
||||
await withTempFile("slice-crlf-test.txt", content, async (filepath) => {
|
||||
const result = await readSlice(filepath, 1, 3)
|
||||
expect(result.lineCount).toBe(3)
|
||||
expect(result.content).not.toContain("\r")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("readIndentationBlock", () => {
|
||||
const pythonCode = `def outer():
|
||||
x = 1
|
||||
def inner():
|
||||
y = 2
|
||||
return y
|
||||
return inner()
|
||||
|
||||
def another():
|
||||
pass`
|
||||
|
||||
it("should extract a function block with its contents", async () => {
|
||||
await withTempFile("indent-function-test.py", pythonCode, async (filepath) => {
|
||||
const result = await readIndentationBlock(filepath, 1, 100, { anchorLine: 3 })
|
||||
// Should include "def inner():" and its body
|
||||
expect(result.content).toContain("def inner():")
|
||||
expect(result.content).toContain("y = 2")
|
||||
expect(result.content).toContain("return y")
|
||||
})
|
||||
})
|
||||
|
||||
it("should respect maxLevels parameter", async () => {
|
||||
await withTempFile("indent-levels-test.py", pythonCode, async (filepath) => {
|
||||
const result = await readIndentationBlock(filepath, 4, 50, {
|
||||
anchorLine: 4,
|
||||
maxLevels: 1,
|
||||
})
|
||||
// With maxLevels=1, should only go up one level from the anchor
|
||||
expect(result.content).toContain("y = 2")
|
||||
})
|
||||
})
|
||||
|
||||
it("should include sibling blocks when includeSiblings is true", async () => {
|
||||
await withTempFile("indent-siblings-test.py", pythonCode, async (filepath) => {
|
||||
const result = await readIndentationBlock(filepath, 1, 100, {
|
||||
anchorLine: 1,
|
||||
includeSiblings: true,
|
||||
})
|
||||
// Should include both functions
|
||||
expect(result.content).toContain("def outer():")
|
||||
expect(result.content).toContain("def another():")
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error for anchorLine=0", async () => {
|
||||
await withTempFile("indent-zero-anchor-test.py", pythonCode, async (filepath) => {
|
||||
await expect(readIndentationBlock(filepath, 1, 100, { anchorLine: 0 })).rejects.toThrow(
|
||||
"anchor_line must be a 1-indexed line number",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when anchorLine exceeds file length", async () => {
|
||||
await withTempFile("indent-past-end-test.py", pythonCode, async (filepath) => {
|
||||
await expect(readIndentationBlock(filepath, 1, 100, { anchorLine: 100 })).rejects.toThrow(
|
||||
"anchor_line exceeds file length",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle single line result", async () => {
|
||||
const content = "single line"
|
||||
await withTempFile("indent-single-test.txt", content, async (filepath) => {
|
||||
const result = await readIndentationBlock(filepath, 1, 1, { anchorLine: 1 })
|
||||
expect(result.lineCount).toBe(1)
|
||||
expect(result.content).toContain("single line")
|
||||
})
|
||||
})
|
||||
|
||||
it("should trim leading/trailing blank lines", async () => {
|
||||
const content = "\n\ndef foo():\n pass\n\n"
|
||||
await withTempFile("indent-trim-test.py", content, async (filepath) => {
|
||||
const result = await readIndentationBlock(filepath, 3, 100, { anchorLine: 3 })
|
||||
// Should not start or end with blank lines
|
||||
const lines = result.content.split("\n").filter((l) => l.trim())
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
expect(lines[0]).toContain("def foo():")
|
||||
})
|
||||
})
|
||||
|
||||
it("should include comment headers when includeHeader is true", async () => {
|
||||
const codeWithComment = `# This is a comment header
|
||||
# describing the function
|
||||
def my_function():
|
||||
return 42`
|
||||
await withTempFile("indent-header-test.py", codeWithComment, async (filepath) => {
|
||||
const result = await readIndentationBlock(filepath, 3, 100, {
|
||||
anchorLine: 3,
|
||||
includeHeader: true,
|
||||
})
|
||||
// Should include the comment header
|
||||
expect(result.content).toContain("# This is a comment header")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("readFileContent", () => {
|
||||
it("should use slice mode by default", async () => {
|
||||
const content = "Line 1\nLine 2\nLine 3"
|
||||
await withTempFile("mode-default-test.txt", content, async (filepath) => {
|
||||
const result = await readFileContent({
|
||||
filePath: filepath,
|
||||
offset: 1,
|
||||
limit: 2,
|
||||
})
|
||||
expect(result.lineCount).toBe(2)
|
||||
expect(result.content).toContain("1 | Line 1")
|
||||
expect(result.content).toContain("2 | Line 2")
|
||||
})
|
||||
})
|
||||
|
||||
it("should use slice mode when explicitly specified", async () => {
|
||||
const content = "Line 1\nLine 2\nLine 3"
|
||||
await withTempFile("mode-slice-test.txt", content, async (filepath) => {
|
||||
const result = await readFileContent({
|
||||
filePath: filepath,
|
||||
offset: 2,
|
||||
limit: 2,
|
||||
mode: "slice",
|
||||
})
|
||||
expect(result.lineCount).toBe(2)
|
||||
expect(result.content).toContain("2 | Line 2")
|
||||
expect(result.content).toContain("3 | Line 3")
|
||||
})
|
||||
})
|
||||
|
||||
it("should use indentation mode when specified", async () => {
|
||||
const pythonCode = `def outer():
|
||||
x = 1
|
||||
return x`
|
||||
await withTempFile("mode-indent-test.py", pythonCode, async (filepath) => {
|
||||
const result = await readFileContent({
|
||||
filePath: filepath,
|
||||
offset: 1,
|
||||
limit: 100,
|
||||
mode: "indentation",
|
||||
indentation: { anchorLine: 2 },
|
||||
})
|
||||
expect(result.content).toContain("x = 1")
|
||||
})
|
||||
})
|
||||
|
||||
it("should use default values when not specified", async () => {
|
||||
const content = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`).join("\n")
|
||||
await withTempFile("mode-defaults-test.txt", content, async (filepath) => {
|
||||
const result = await readFileContent({
|
||||
filePath: filepath,
|
||||
})
|
||||
// Should use default offset=1 and default limit
|
||||
expect(result.content).toContain("1 | Line 1")
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty files gracefully", async () => {
|
||||
await withTempFile("mode-empty-test.txt", "", async (filepath) => {
|
||||
await expect(readFileContent({ filePath: filepath })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle Unicode content", async () => {
|
||||
const content = "Hello 👋\nWorld 🌍\nTest 测试"
|
||||
await withTempFile("mode-unicode-test.txt", content, async (filepath) => {
|
||||
const result = await readFileContent({
|
||||
filePath: filepath,
|
||||
mode: "slice",
|
||||
})
|
||||
expect(result.content).toContain("Hello 👋")
|
||||
expect(result.content).toContain("World 🌍")
|
||||
expect(result.content).toContain("Test 测试")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
557
src/integrations/misc/read-file-content.ts
Normal file
557
src/integrations/misc/read-file-content.ts
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
/**
|
||||
* Read file content utilities with advanced features:
|
||||
* - Slice mode: Simple line-by-line reading with offset and limit
|
||||
* - Indentation mode: Smart extraction of code blocks based on indentation levels
|
||||
*/
|
||||
import { createReadStream } from "fs"
|
||||
|
||||
// Configuration constants
|
||||
const MAX_LINE_LENGTH = 500 // Truncate lines longer than this
|
||||
const TAB_WIDTH = 4 // Treat tabs as 4 spaces for indentation
|
||||
const FALLBACK_LIMIT = 500 // Fallback when no limit is specified and maxReadFileLine is not set
|
||||
|
||||
// Comment prefixes for header detection
|
||||
const COMMENT_PREFIXES = ["#", "//", "--", "/*", "*", "'''", '"""', "<!--"]
|
||||
|
||||
/**
|
||||
* Record for a single line in a file
|
||||
*/
|
||||
export interface LineRecord {
|
||||
number: number // 1-indexed line number
|
||||
raw: string // Original line content
|
||||
display: string // Formatted/truncated line for output
|
||||
indent: number // Measured indentation in spaces
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for indentation mode
|
||||
*/
|
||||
export interface IndentationConfig {
|
||||
anchorLine?: number // The line to anchor the block expansion from (defaults to offset)
|
||||
maxLevels?: number // Maximum indentation depth to collect; 0 = unlimited
|
||||
includeSiblings?: boolean // Whether to include sibling blocks at same indentation level
|
||||
includeHeader?: boolean // Whether to include comment headers above the anchor block
|
||||
maxLines?: number // Hard cap on returned lines (defaults to limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read mode options
|
||||
*/
|
||||
export type ReadMode = "slice" | "indentation"
|
||||
|
||||
/**
|
||||
* Options for reading file content
|
||||
*/
|
||||
export interface ReadFileContentOptions {
|
||||
filePath: string
|
||||
offset?: number // 1-indexed line number to start reading from (default: 1)
|
||||
limit?: number // Maximum number of lines to return (uses defaultLimit if not specified)
|
||||
mode?: ReadMode // Either "slice" or "indentation" (default: "slice")
|
||||
indentation?: IndentationConfig // Configuration for indentation mode
|
||||
defaultLimit?: number // Default limit to use when limit is not specified (usually from maxReadFileLine setting)
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about the file read operation for pagination awareness
|
||||
*/
|
||||
export interface ReadFileMetadata {
|
||||
/** The file path that was read */
|
||||
filePath: string
|
||||
/** Total number of lines in the file */
|
||||
totalLinesInFile: number
|
||||
/** Number of lines returned in the response */
|
||||
linesReturned: number
|
||||
/** First line number in the response (1-indexed) */
|
||||
startLine: number
|
||||
/** Last line number in the response (1-indexed) */
|
||||
endLine: number
|
||||
/** True if there are lines before startLine */
|
||||
hasMoreBefore: boolean
|
||||
/** True if there are lines after endLine */
|
||||
hasMoreAfter: boolean
|
||||
/** Count of lines before the returned content */
|
||||
linesBeforeStart: number
|
||||
/** Count of lines after the returned content */
|
||||
linesAfterEnd: number
|
||||
/** True if reading stopped due to limit parameter */
|
||||
truncatedByLimit: boolean
|
||||
/** Line numbers where content was truncated due to MAX_LINE_LENGTH */
|
||||
lineLengthTruncations: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of reading file content
|
||||
*/
|
||||
export interface ReadFileContentResult {
|
||||
content: string // The formatted content with line numbers
|
||||
lineCount: number // Number of lines returned
|
||||
totalLines: number // Total lines in file (for slice mode)
|
||||
metadata: ReadFileMetadata // Detailed metadata for pagination awareness
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is a comment line
|
||||
*/
|
||||
function isCommentLine(line: string): boolean {
|
||||
const trimmed = line.trim()
|
||||
return COMMENT_PREFIXES.some((prefix) => trimmed.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is blank (whitespace only)
|
||||
*/
|
||||
function isBlankLine(line: string): boolean {
|
||||
return line.trim().length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure indentation of a line, treating tabs as TAB_WIDTH spaces
|
||||
*/
|
||||
export function measureIndent(line: string): number {
|
||||
let indent = 0
|
||||
for (const char of line) {
|
||||
if (char === " ") {
|
||||
indent += 1
|
||||
} else if (char === "\t") {
|
||||
indent += TAB_WIDTH
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return indent
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute effective indentation for each line, propagating through blanks
|
||||
* Blank lines inherit the indentation of the previous non-blank line
|
||||
*/
|
||||
function computeEffectiveIndents(records: LineRecord[]): number[] {
|
||||
const effective: number[] = []
|
||||
let previousIndent = 0
|
||||
|
||||
for (const record of records) {
|
||||
if (isBlankLine(record.raw)) {
|
||||
effective.push(previousIndent)
|
||||
} else {
|
||||
previousIndent = record.indent
|
||||
effective.push(previousIndent)
|
||||
}
|
||||
}
|
||||
|
||||
return effective
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a LineRecord from a line of text
|
||||
*/
|
||||
function createLineRecord(lineNumber: number, raw: string): LineRecord {
|
||||
const indent = measureIndent(raw)
|
||||
const display = raw.length > MAX_LINE_LENGTH ? raw.substring(0, MAX_LINE_LENGTH) : raw
|
||||
|
||||
return {
|
||||
number: lineNumber,
|
||||
raw,
|
||||
display,
|
||||
indent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all lines from a file into LineRecord array
|
||||
*/
|
||||
async function collectFileLines(filePath: string): Promise<LineRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const records: LineRecord[] = []
|
||||
let lineNumber = 0
|
||||
let buffer = ""
|
||||
|
||||
const input = createReadStream(filePath)
|
||||
|
||||
input.on("error", reject)
|
||||
|
||||
input.on("data", (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
|
||||
let pos = 0
|
||||
let nextNewline = buffer.indexOf("\n", pos)
|
||||
|
||||
while (nextNewline !== -1) {
|
||||
let line = buffer.substring(pos, nextNewline)
|
||||
// Strip \r if present (CRLF)
|
||||
if (line.endsWith("\r")) {
|
||||
line = line.slice(0, -1)
|
||||
}
|
||||
|
||||
lineNumber++
|
||||
records.push(createLineRecord(lineNumber, line))
|
||||
|
||||
pos = nextNewline + 1
|
||||
nextNewline = buffer.indexOf("\n", pos)
|
||||
}
|
||||
|
||||
// Keep incomplete line in buffer
|
||||
buffer = buffer.substring(pos)
|
||||
})
|
||||
|
||||
input.on("end", () => {
|
||||
// Process any remaining data (last line without newline)
|
||||
if (buffer.length > 0) {
|
||||
lineNumber++
|
||||
records.push(createLineRecord(lineNumber, buffer))
|
||||
}
|
||||
resolve(records)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file using slice mode - simple line-by-line reading with offset and limit
|
||||
*/
|
||||
export async function readSlice(
|
||||
filePath: string,
|
||||
offset: number = 1,
|
||||
limit: number = FALLBACK_LIMIT,
|
||||
): Promise<ReadFileContentResult> {
|
||||
if (offset === 0) {
|
||||
throw new RangeError("offset must be a 1-indexed line number")
|
||||
}
|
||||
if (limit === 0) {
|
||||
throw new RangeError("limit must be greater than zero")
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const collected: string[] = []
|
||||
const lineLengthTruncations: number[] = []
|
||||
let lineNumber = 0
|
||||
let buffer = ""
|
||||
let startLine = 0
|
||||
let endLine = 0
|
||||
let truncatedByLimit = false
|
||||
|
||||
const input = createReadStream(filePath)
|
||||
|
||||
input.on("error", reject)
|
||||
|
||||
input.on("data", (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
|
||||
let pos = 0
|
||||
let nextNewline = buffer.indexOf("\n", pos)
|
||||
|
||||
while (nextNewline !== -1) {
|
||||
let line = buffer.substring(pos, nextNewline)
|
||||
// Strip \r if present (CRLF)
|
||||
if (line.endsWith("\r")) {
|
||||
line = line.slice(0, -1)
|
||||
}
|
||||
|
||||
lineNumber++
|
||||
|
||||
if (lineNumber >= offset && collected.length < limit) {
|
||||
// Track first line collected
|
||||
if (startLine === 0) {
|
||||
startLine = lineNumber
|
||||
}
|
||||
endLine = lineNumber
|
||||
|
||||
// Truncate long lines and track truncation
|
||||
let display = line
|
||||
if (line.length > MAX_LINE_LENGTH) {
|
||||
display = line.substring(0, MAX_LINE_LENGTH)
|
||||
lineLengthTruncations.push(lineNumber)
|
||||
}
|
||||
collected.push(`${lineNumber} | ${display}`)
|
||||
}
|
||||
|
||||
if (collected.length >= limit) {
|
||||
truncatedByLimit = true
|
||||
input.destroy()
|
||||
// We don't know the total lines yet, so estimate
|
||||
const totalLines = lineNumber
|
||||
const linesReturned = collected.length
|
||||
resolve({
|
||||
content: collected.join("\n"),
|
||||
lineCount: linesReturned,
|
||||
totalLines,
|
||||
metadata: {
|
||||
filePath,
|
||||
totalLinesInFile: totalLines, // Approximate - file was truncated early
|
||||
linesReturned,
|
||||
startLine,
|
||||
endLine,
|
||||
hasMoreBefore: startLine > 1,
|
||||
hasMoreAfter: true, // We hit the limit, so there's likely more
|
||||
linesBeforeStart: startLine - 1,
|
||||
linesAfterEnd: 0, // Unknown when truncated
|
||||
truncatedByLimit: true,
|
||||
lineLengthTruncations,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pos = nextNewline + 1
|
||||
nextNewline = buffer.indexOf("\n", pos)
|
||||
}
|
||||
|
||||
buffer = buffer.substring(pos)
|
||||
})
|
||||
|
||||
input.on("end", () => {
|
||||
// Process any remaining data (last line without newline)
|
||||
if (buffer.length > 0) {
|
||||
lineNumber++
|
||||
if (lineNumber >= offset && collected.length < limit) {
|
||||
if (startLine === 0) {
|
||||
startLine = lineNumber
|
||||
}
|
||||
endLine = lineNumber
|
||||
|
||||
let display = buffer
|
||||
if (buffer.length > MAX_LINE_LENGTH) {
|
||||
display = buffer.substring(0, MAX_LINE_LENGTH)
|
||||
lineLengthTruncations.push(lineNumber)
|
||||
}
|
||||
collected.push(`${lineNumber} | ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (lineNumber < offset) {
|
||||
reject(new RangeError("offset exceeds file length"))
|
||||
return
|
||||
}
|
||||
|
||||
const totalLines = lineNumber
|
||||
const linesReturned = collected.length
|
||||
const linesAfterEnd = endLine > 0 ? totalLines - endLine : 0
|
||||
|
||||
resolve({
|
||||
content: collected.join("\n"),
|
||||
lineCount: linesReturned,
|
||||
totalLines,
|
||||
metadata: {
|
||||
filePath,
|
||||
totalLinesInFile: totalLines,
|
||||
linesReturned,
|
||||
startLine: startLine || offset,
|
||||
endLine: endLine || offset,
|
||||
hasMoreBefore: (startLine || offset) > 1,
|
||||
hasMoreAfter: linesAfterEnd > 0,
|
||||
linesBeforeStart: (startLine || offset) - 1,
|
||||
linesAfterEnd,
|
||||
truncatedByLimit,
|
||||
lineLengthTruncations,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file using indentation mode - smart extraction of code blocks
|
||||
*/
|
||||
export async function readIndentationBlock(
|
||||
filePath: string,
|
||||
offset: number = 1,
|
||||
limit: number = FALLBACK_LIMIT,
|
||||
config: IndentationConfig = {},
|
||||
): Promise<ReadFileContentResult> {
|
||||
const {
|
||||
anchorLine = offset,
|
||||
maxLevels = 0,
|
||||
includeSiblings = false,
|
||||
includeHeader = true,
|
||||
maxLines = limit,
|
||||
} = config
|
||||
|
||||
if (anchorLine === 0) {
|
||||
throw new RangeError("anchor_line must be a 1-indexed line number")
|
||||
}
|
||||
if (maxLines === 0) {
|
||||
throw new RangeError("max_lines must be greater than zero")
|
||||
}
|
||||
|
||||
// Load all lines
|
||||
const records = await collectFileLines(filePath)
|
||||
|
||||
if (records.length === 0 || anchorLine > records.length) {
|
||||
throw new RangeError("anchor_line exceeds file length")
|
||||
}
|
||||
|
||||
const anchorIndex = anchorLine - 1
|
||||
const effectiveIndents = computeEffectiveIndents(records)
|
||||
const anchorIndent = effectiveIndents[anchorIndex]
|
||||
|
||||
// Calculate minimum indentation threshold
|
||||
let minIndent: number
|
||||
if (maxLevels === 0) {
|
||||
minIndent = 0
|
||||
} else {
|
||||
minIndent = Math.max(0, anchorIndent - maxLevels * TAB_WIDTH)
|
||||
}
|
||||
|
||||
// Cap by limits
|
||||
const finalLimit = Math.min(limit, maxLines, records.length)
|
||||
|
||||
// Special case: single line
|
||||
if (finalLimit === 1) {
|
||||
const r = records[anchorIndex]
|
||||
const lineLengthTruncations: number[] = r.raw.length > MAX_LINE_LENGTH ? [r.number] : []
|
||||
return {
|
||||
content: `${r.number} | ${r.display}`,
|
||||
lineCount: 1,
|
||||
totalLines: records.length,
|
||||
metadata: {
|
||||
filePath,
|
||||
totalLinesInFile: records.length,
|
||||
linesReturned: 1,
|
||||
startLine: r.number,
|
||||
endLine: r.number,
|
||||
hasMoreBefore: r.number > 1,
|
||||
hasMoreAfter: r.number < records.length,
|
||||
linesBeforeStart: r.number - 1,
|
||||
linesAfterEnd: records.length - r.number,
|
||||
truncatedByLimit: true,
|
||||
lineLengthTruncations,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectional expansion from anchor using a deque-like array
|
||||
const out: LineRecord[] = [records[anchorIndex]]
|
||||
let i = anchorIndex - 1 // upward cursor
|
||||
let j = anchorIndex + 1 // downward cursor
|
||||
let iMinIndentCount = 0
|
||||
let jMinIndentCount = 0
|
||||
|
||||
while (out.length < finalLimit) {
|
||||
let progressed = 0
|
||||
|
||||
// Expand upward
|
||||
if (i >= 0) {
|
||||
if (effectiveIndents[i] >= minIndent) {
|
||||
out.unshift(records[i])
|
||||
progressed++
|
||||
|
||||
// Handle sibling exclusion
|
||||
if (effectiveIndents[i] === minIndent && !includeSiblings) {
|
||||
const allowHeader = includeHeader && isCommentLine(records[i].raw)
|
||||
const canTake = allowHeader || iMinIndentCount === 0
|
||||
|
||||
if (canTake) {
|
||||
iMinIndentCount++
|
||||
} else {
|
||||
out.shift()
|
||||
progressed--
|
||||
i = -1 // Stop upward expansion
|
||||
}
|
||||
}
|
||||
|
||||
if (i >= 0) {
|
||||
i--
|
||||
}
|
||||
|
||||
if (out.length >= finalLimit) {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
i = -1 // Stop upward expansion
|
||||
}
|
||||
}
|
||||
|
||||
// Expand downward
|
||||
if (j < records.length) {
|
||||
if (effectiveIndents[j] >= minIndent) {
|
||||
out.push(records[j])
|
||||
progressed++
|
||||
|
||||
// Handle sibling exclusion
|
||||
if (effectiveIndents[j] === minIndent && !includeSiblings) {
|
||||
if (jMinIndentCount > 0) {
|
||||
out.pop()
|
||||
progressed--
|
||||
j = records.length // Stop downward expansion
|
||||
}
|
||||
jMinIndentCount++
|
||||
}
|
||||
|
||||
if (j < records.length) {
|
||||
j++
|
||||
}
|
||||
} else {
|
||||
j = records.length // Stop downward expansion
|
||||
}
|
||||
}
|
||||
|
||||
if (progressed === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Trim leading/trailing empty lines
|
||||
while (out.length > 0 && isBlankLine(out[0].raw)) {
|
||||
out.shift()
|
||||
}
|
||||
while (out.length > 0 && isBlankLine(out[out.length - 1].raw)) {
|
||||
out.pop()
|
||||
}
|
||||
|
||||
// Track line length truncations
|
||||
const lineLengthTruncations: number[] = out.filter((r) => r.raw.length > MAX_LINE_LENGTH).map((r) => r.number)
|
||||
|
||||
// Format output
|
||||
const content = out.map((r) => `${r.number} | ${r.display}`).join("\n")
|
||||
|
||||
// Calculate metadata
|
||||
const linesReturned = out.length
|
||||
const startLine = out.length > 0 ? out[0].number : anchorLine
|
||||
const endLine = out.length > 0 ? out[out.length - 1].number : anchorLine
|
||||
const totalLines = records.length
|
||||
const linesAfterEnd = totalLines - endLine
|
||||
|
||||
// Determine if truncated by limit (expansion stopped due to limit, not natural boundaries)
|
||||
const truncatedByLimit = out.length >= finalLimit
|
||||
|
||||
return {
|
||||
content,
|
||||
lineCount: linesReturned,
|
||||
totalLines,
|
||||
metadata: {
|
||||
filePath,
|
||||
totalLinesInFile: totalLines,
|
||||
linesReturned,
|
||||
startLine,
|
||||
endLine,
|
||||
hasMoreBefore: startLine > 1,
|
||||
hasMoreAfter: linesAfterEnd > 0,
|
||||
linesBeforeStart: startLine - 1,
|
||||
linesAfterEnd,
|
||||
truncatedByLimit,
|
||||
lineLengthTruncations,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to read file content with either slice or indentation mode
|
||||
*/
|
||||
export async function readFileContent(options: ReadFileContentOptions): Promise<ReadFileContentResult> {
|
||||
const { filePath, offset = 1, limit, mode = "slice", indentation, defaultLimit } = options
|
||||
|
||||
// Use provided limit, or defaultLimit (from maxReadFileLine setting), or fallback
|
||||
const effectiveLimit = limit ?? defaultLimit ?? FALLBACK_LIMIT
|
||||
|
||||
if (mode === "indentation") {
|
||||
return readIndentationBlock(filePath, offset, effectiveLimit, indentation)
|
||||
} else {
|
||||
return readSlice(filePath, offset, effectiveLimit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add line numbers to content string (for compatibility with existing code)
|
||||
*/
|
||||
export function addLineNumbers(content: string, startLine: number = 1): string {
|
||||
const lines = content.split("\n")
|
||||
return lines.map((line, index) => `${startLine + index} | ${line}`).join("\n")
|
||||
}
|
||||
|
|
@ -263,7 +263,7 @@ export const ContextManagementSettings = ({
|
|||
<VSCodeCheckbox
|
||||
checked={maxReadFileLine === -1}
|
||||
onChange={(e: any) =>
|
||||
setCachedStateField("maxReadFileLine", e.target.checked ? -1 : 500)
|
||||
setCachedStateField("maxReadFileLine", e.target.checked ? -1 : 2000)
|
||||
}
|
||||
data-testid="max-read-file-always-full-checkbox">
|
||||
{t("settings:contextManagement.maxReadFile.always_full_read")}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue