Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-file-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Read large text files in bounded memory and read tail lines without scanning whole files.
167 changes: 136 additions & 31 deletions packages/agent-core/src/tools/builtin/file/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ type TextPreviewKaos = Kaos & {
readTextPreview?: (path: string, n: number) => Promise<Buffer>;
};

interface TextFileScan {
totalLines: number;
endsWithNewline: boolean;
hasNul: boolean;
lineEndingFlags: LineEndingFlags;
}

type RangeReadKaos = TextPreviewKaos & {
scanTextFile?: (path: string) => Promise<TextFileScan>;
readLineRange?: (
path: string,
options: { startLine: number; maxLines: number; errors?: 'strict' | 'replace' | 'ignore' },
) => AsyncGenerator<string>;
readTailLines?: (
path: string,
options: { tailCount: number; errors?: 'strict' | 'replace' | 'ignore' },
) => AsyncGenerator<string>;
};

async function readTextHeader(kaos: TextPreviewKaos, path: string, n: number): Promise<Buffer> {
if (kaos.readTextPreview !== undefined) {
return kaos.readTextPreview(path, n);
Expand Down Expand Up @@ -141,6 +160,41 @@ function renderedLineBytes(renderedLine: string, isFirst: boolean): number {
return (isFirst ? 0 : 1) + Buffer.byteLength(renderedLine, 'utf8');
}

function renderEntries(
entries: readonly ReadLineEntry[],
lineEndingStyle: LineEndingStyle,
): {
renderedLines: string[];
truncatedLineNumbers: number[];
maxBytesReached: boolean;
} {
const renderedLines: string[] = [];
const truncatedLineNumbers: number[] = [];
let bytes = 0;
let maxBytesReached = false;

for (const entry of entries) {
const rendered = renderLine(entry, lineEndingStyle);
const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0);
if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) {
maxBytesReached = true;
break;
}

if (rendered.wasTruncated) {
truncatedLineNumbers.push(entry.lineNo);
}
renderedLines.push(rendered.line);
bytes += lineBytes;
if (bytes >= MAX_BYTES) {
maxBytesReached = true;
break;
}
}

return { renderedLines, truncatedLineNumbers, maxBytesReached };
}

function isRegularFileMode(stMode: number): boolean {
return (stMode & S_IFMT) === S_IFREG;
}
Expand Down Expand Up @@ -275,6 +329,36 @@ export class ReadTool implements BuiltinTool<ReadInput> {
effectiveLimit: number,
requestedLines: number,
): Promise<ExecutableToolResult> {
const rangeKaos = this.kaos as RangeReadKaos;
if (rangeKaos.scanTextFile !== undefined && rangeKaos.readLineRange !== undefined) {
const scan = await rangeKaos.scanTextFile(safePath);
if (scan.hasNul) {
return { isError: true, output: notReadableFileOutput(displayPath) };
}
const selectedEntries: ReadLineEntry[] = [];
let lineNo = lineOffset;
for await (const rawLine of rangeKaos.readLineRange(safePath, {
startLine: lineOffset,
maxLines: effectiveLimit,
errors: 'strict',
})) {
selectedEntries.push({ lineNo, rawContent: stripTrailingLf(rawLine) });
lineNo += 1;
}
const lineEndingStyle = lineEndingStyleFromFlags(scan.lineEndingFlags);
const rendered = renderEntries(selectedEntries, lineEndingStyle);
return this.finishReadResult({
renderedLines: rendered.renderedLines,
truncatedLineNumbers: rendered.truncatedLineNumbers,
maxLinesReached: effectiveLimit >= MAX_LINES && lineOffset + MAX_LINES <= scan.totalLines,
maxBytesReached: rendered.maxBytesReached,
lineEndingStyle,
startLine: selectedEntries.length > 0 ? lineOffset : 0,
totalLines: scan.totalLines,
requestedLines,
});
}

const selectedEntries: ReadLineEntry[] = [];
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
let currentLineNo = 0;
Expand Down Expand Up @@ -311,37 +395,15 @@ export class ReadTool implements BuiltinTool<ReadInput> {
}

const lineEndingStyle = lineEndingStyleFromFlags(flags);
const renderedLines: string[] = [];
const truncatedLineNumbers: number[] = [];
let bytes = 0;
let maxBytesReached = false;

for (const entry of selectedEntries) {
const rendered = renderLine(entry, lineEndingStyle);
const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0);
if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) {
maxBytesReached = true;
break;
}

if (rendered.wasTruncated) {
truncatedLineNumbers.push(entry.lineNo);
}
renderedLines.push(rendered.line);
bytes += lineBytes;
if (bytes >= MAX_BYTES) {
maxBytesReached = true;
break;
}
}
const rendered = renderEntries(selectedEntries, lineEndingStyle);

return this.finishReadResult({
renderedLines,
truncatedLineNumbers,
renderedLines: rendered.renderedLines,
truncatedLineNumbers: rendered.truncatedLineNumbers,
maxLinesReached,
maxBytesReached,
maxBytesReached: rendered.maxBytesReached,
lineEndingStyle,
startLine: renderedLines.length > 0 ? lineOffset : 0,
startLine: selectedEntries.length > 0 ? lineOffset : 0,
totalLines: currentLineNo,
requestedLines,
});
Expand All @@ -355,6 +417,33 @@ export class ReadTool implements BuiltinTool<ReadInput> {
requestedLines: number,
): Promise<ExecutableToolResult> {
const tailCount = Math.abs(lineOffset);
const rangeKaos = this.kaos as RangeReadKaos;
if (rangeKaos.scanTextFile !== undefined && rangeKaos.readTailLines !== undefined) {
const scan = await rangeKaos.scanTextFile(safePath);
if (scan.hasNul) {
return { isError: true, output: notReadableFileOutput(displayPath) };
}
const rawLines: string[] = [];
for await (const rawLine of rangeKaos.readTailLines(safePath, {
tailCount,
errors: 'strict',
})) {
rawLines.push(rawLine);
}
const startLine = Math.max(1, scan.totalLines - rawLines.length + 1);
const entries = rawLines.map((rawLine, index) => ({
lineNo: startLine + index,
rawContent: stripTrailingLf(rawLine),
}));
return this.finishTailEntries({
entries,
lineEndingFlags: scan.lineEndingFlags,
effectiveLimit,
totalLines: scan.totalLines,
requestedLines,
});
}

const entries: ReadLineEntry[] = [];
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
let currentLineNo = 0;
Expand All @@ -374,8 +463,24 @@ export class ReadTool implements BuiltinTool<ReadInput> {
}
}

const lineEndingStyle = lineEndingStyleFromFlags(flags);
let renderedCandidates = entries.slice(0, effectiveLimit).map((entry) => {
return this.finishTailEntries({
entries,
lineEndingFlags: flags,
effectiveLimit,
totalLines: currentLineNo,
requestedLines,
});
}

private finishTailEntries(input: {
entries: readonly ReadLineEntry[];
lineEndingFlags: LineEndingFlags;
effectiveLimit: number;
totalLines: number;
requestedLines: number;
}): ExecutableToolResult {
const lineEndingStyle = lineEndingStyleFromFlags(input.lineEndingFlags);
let renderedCandidates = input.entries.slice(0, input.effectiveLimit).map((entry) => {
return { entry, rendered: renderLine(entry, lineEndingStyle) };
});

Expand Down Expand Up @@ -416,8 +521,8 @@ export class ReadTool implements BuiltinTool<ReadInput> {
maxBytesReached,
lineEndingStyle,
startLine: renderedCandidates[0]?.entry.lineNo ?? 0,
totalLines: currentLineNo,
requestedLines,
totalLines: input.totalLines,
requestedLines: input.requestedLines,
});
}

Expand Down
107 changes: 107 additions & 0 deletions packages/agent-core/test/tools/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,113 @@ describe('ReadTool', () => {
expect(readText).not.toHaveBeenCalled();
});

it('uses range reader when available without consuming readLines', async () => {
const content = Array.from({ length: 20 }, (_, i) => `line ${String(i + 1)}`).join('\n');
const bytes = Buffer.from(content, 'utf8');
const readLines = vi.fn<Kaos['readLines']>();
const readLineRange = vi.fn(async function* readLineRange(
_path: string,
options: { startLine: number; maxLines: number },
): AsyncGenerator<string> {
for (let i = options.startLine; i < options.startLine + options.maxLines; i += 1) {
yield `line ${String(i)}\n`;
}
});
const scanTextFile = vi.fn(async () => ({
totalLines: 20,
endsWithNewline: false,
hasNul: false,
lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: false },
}));
const tool = new ReadTool(
createFakeKaos({
stat: vi.fn<Kaos['stat']>().mockResolvedValue(REGULAR_FILE_STAT),
readBytes: vi.fn<Kaos['readBytes']>().mockImplementation(async (_path, n) => {
return n === undefined ? bytes : bytes.subarray(0, n);
}),
readLines,
scanTextFile,
readLineRange,
} as unknown as Partial<Kaos>),
PERMISSIVE_WORKSPACE,
);

const result = await executeTool(tool, context({ path: '/tmp/range.txt', line_offset: 5, n_lines: 3 }));
const output = toolContentString(result);

expect(output).toContain('5\tline 5');
expect(output).toContain('7\tline 7');
expect(output).not.toContain('8\tline 8');
expect(readLineRange).toHaveBeenCalledWith('/tmp/range.txt', {
startLine: 5,
maxLines: 3,
errors: 'strict',
});
expect(readLines).not.toHaveBeenCalled();
});

it('uses tail reader when available without consuming readLines', async () => {
const content = Array.from({ length: 20 }, (_, i) => `line ${String(i + 1)}`).join('\n');
const bytes = Buffer.from(content, 'utf8');
const readLines = vi.fn<Kaos['readLines']>();
const readTailLines = vi.fn(async function* readTailLines(): AsyncGenerator<string> {
yield 'line 18\n';
yield 'line 19\n';
yield 'line 20';
});
const scanTextFile = vi.fn(async () => ({
totalLines: 20,
endsWithNewline: false,
hasNul: false,
lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: false },
}));
const tool = new ReadTool(
createFakeKaos({
stat: vi.fn<Kaos['stat']>().mockResolvedValue(REGULAR_FILE_STAT),
readBytes: vi.fn<Kaos['readBytes']>().mockImplementation(async (_path, n) => {
return n === undefined ? bytes : bytes.subarray(0, n);
}),
readLines,
scanTextFile,
readTailLines,
} as unknown as Partial<Kaos>),
PERMISSIVE_WORKSPACE,
);

const result = await executeTool(tool, context({ path: '/tmp/tail.txt', line_offset: -3 }));
const output = toolContentString(result);

expect(output).toContain('18\tline 18');
expect(output).toContain('20\tline 20');
expect(readTailLines).toHaveBeenCalledWith('/tmp/tail.txt', { tailCount: 3, errors: 'strict' });
expect(readLines).not.toHaveBeenCalled();
});

it('short-circuits on scan NUL before range read', async () => {
const readLineRange = vi.fn();
const tool = new ReadTool(
createFakeKaos({
stat: vi.fn<Kaos['stat']>().mockResolvedValue(REGULAR_FILE_STAT),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(Buffer.from('text')),
scanTextFile: vi.fn(async () => ({
totalLines: 1,
endsWithNewline: false,
hasNul: true,
lineEndingFlags: { hasCrLf: false, hasLf: false, hasLoneCr: false },
})),
readLineRange,
} as unknown as Partial<Kaos>),
PERMISSIVE_WORKSPACE,
);

const result = await executeTool(tool, context({ path: '/tmp/nul.txt' }));
const output = toolContentString(result);

expect(result.isError).toBe(true);
expect(output).toContain('is not readable as UTF-8 text');
expect(readLineRange).not.toHaveBeenCalled();
});

it('caps default reads at MAX_LINES', async () => {
const content = Array.from({ length: MAX_LINES + 1 }, (_, i) => `line ${String(i + 1)}`).join(
'\n',
Expand Down
5 changes: 3 additions & 2 deletions packages/kaos/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export function decodeTextWithErrors(
data: Buffer,
encoding: BufferEncoding,
errors: 'strict' | 'replace' | 'ignore' = 'strict',
ignoreBOM: boolean = false,
): string {
// Map Node's BufferEncoding names to Web TextDecoder labels where the two
// diverge. Only UTF-family encodings participate in the strict/replace/
Expand Down Expand Up @@ -163,7 +164,7 @@ export function decodeTextWithErrors(
}

if (errors === 'strict') {
return new TextDecoder(webLabel, { fatal: true }).decode(data);
return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data);
}

// 'ignore' must skip invalid input bytes/code units, not delete every
Expand All @@ -174,7 +175,7 @@ export function decodeTextWithErrors(
}

// 'replace' → substitute each invalid sequence with U+FFFD (default).
return new TextDecoder(webLabel, { fatal: false }).decode(data);
return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data);
}

/**
Expand Down
Loading
Loading