Skip to content

Commit 9774b0a

Browse files
author
easonLiangWorldedtech
committed
feat(read-file): record read scope and report clipping separately from omission (U4, issue 1375)
Split unit U4 of PR 1833. A slice that cut lines off and a slice that clipped a long line are two different facts, and the observation a read earns is only complete when it saw the whole file.
1 parent e4fd089 commit 9774b0a

5 files changed

Lines changed: 2653 additions & 1741 deletions

File tree

‎src/core/tools/ReadFileTool.ts‎

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR
1616
import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types"
1717

1818
import { Task } from "../task/Task"
19+
import { versionTokenOfStat } from "../../utils/versionToken"
1920
import { formatResponse } from "../prompts/responses"
2021
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
2122
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
2223
import { getReadablePath } from "../../utils/path"
2324
import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
2425
import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader"
25-
import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
26+
import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "../prompts/tools/native-tools/read_file"
2627
import type { ToolUse, PushToolResult } from "../../shared/tools"
2728

2829
import {
@@ -214,14 +215,36 @@ export class ReadFileTool extends BaseTool<"read_file"> {
214215
// Read text file content with lossy UTF-8 conversion
215216
// Reading as Buffer first allows graceful handling of non-UTF8 bytes
216217
// (they become U+FFFD replacement characters instead of throwing)
218+
// A2 (epic #1375): capture the on-disk token before the read so a mutation
219+
// landing mid-read is detected by the post-read stat below.
220+
const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined)
217221
const buffer = await fs.readFile(fullPath)
218222
const fileContent = buffer.toString("utf-8")
219-
const result = this.processTextFile(fileContent, entry)
223+
// A lossy decode is not the whole file: the model never saw those bytes.
224+
const lossyDecode = !Buffer.from(fileContent).equals(buffer)
225+
// S4b follow-up (#46 / epic #1375): processTextFile reports whether the
226+
// returned content is the whole file; the observation below records that
227+
// scope so the write guard can deny full-file updates built on a partial view.
228+
const processed = this.processTextFile(fileContent, entry)
220229

221230
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
222231

232+
// A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard.
233+
// The token is captured before AND after the read; the target is observed only
234+
// when both match — a mutation between the two stats means the content the model
235+
// received is not the on-disk state, and observing it would let a later write
236+
// match a token the model never saw. A stat failure leaves the target
237+
// unobserved and never fails the read.
238+
const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined)
239+
if (preReadStats && postReadStats) {
240+
const preReadToken = versionTokenOfStat(preReadStats)
241+
if (preReadToken === versionTokenOfStat(postReadStats)) {
242+
task.observationRegistry.observe(fullPath, preReadToken, processed.complete && !lossyDecode)
243+
}
244+
}
245+
223246
updateFileResult(relPath, {
224-
nativeContent: `File: ${relPath}\n${result}`,
247+
nativeContent: `File: ${relPath}\n${processed.content}`,
225248
})
226249
} catch (error) {
227250
const errorMsg = error instanceof Error ? error.message : String(error)
@@ -265,8 +288,14 @@ export class ReadFileTool extends BaseTool<"read_file"> {
265288

266289
/**
267290
* Process a text file according to the requested mode.
291+
*
292+
* Returns the content string plus whether that content is the complete
293+
* file (S4b follow-up #46 / epic #1375): slice mode is complete only
294+
* when it starts at line 1, returns every line, and was not truncated;
295+
* indentation mode is never complete because it returns semantic blocks
296+
* of the file, not the file itself.
268297
*/
269-
private processTextFile(content: string, entry: InternalFileEntry): string {
298+
private processTextFile(content: string, entry: InternalFileEntry): { content: string; complete: boolean } {
270299
const mode = entry.mode || "slice"
271300

272301
if (mode === "indentation") {
@@ -299,7 +328,8 @@ export class ReadFileTool extends BaseTool<"read_file"> {
299328
output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)`
300329
}
301330

302-
return output
331+
// Indentation mode returns semantic blocks: never a complete file view.
332+
return { content: output, complete: false }
303333
}
304334

305335
// Slice mode (default): simple offset/limit reading
@@ -322,11 +352,28 @@ export class ReadFileTool extends BaseTool<"read_file"> {
322352
To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}.
323353
324354
${result.content}`
355+
if (result.hasClippedLines) {
356+
// The slice cut lines off and also clipped long lines inside it, so both
357+
// notices belong to the response.
358+
output += `\nNote: Some lines in this view exceed ${MAX_LINE_LENGTH} characters and were clipped in this view.`
359+
}
360+
} else if (result.hasClippedLines) {
361+
// Every line was returned, so there is no later offset to read: report the
362+
// clipping without a next-offset hint, and keep the read incomplete so a
363+
// full-file replacement cannot be built from a clipped line.
364+
output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full.
365+
${result.content}`
325366
} else if (result.returnedLines === 0) {
326367
output = "Note: File is empty"
327368
}
328369

329-
return output
370+
// Complete only when the slice starts at line 1, returned every line, and
371+
// showed every line in full (returnedLines === totalLines follows from the
372+
// first two conditions): a partial start, a truncated tail, or a clipped
373+
// line means the model did not see the whole file.
374+
const complete = offset0 === 0 && !result.wasTruncated && !result.hasClippedLines
375+
376+
return { content: output, complete }
330377
}
331378

332379
/**
@@ -768,9 +815,20 @@ export class ReadFileTool extends BaseTool<"read_file"> {
768815
}
769816

770817
// Read text file
771-
const rawContent = await fs.readFile(fullPath, "utf8")
818+
// A2 (epic #1375): capture the on-disk token before the read so a mutation
819+
// landing mid-read is detected by the post-read stat below.
820+
const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined)
821+
const rawBuffer = await fs.readFile(fullPath)
822+
const rawContent = rawBuffer.toString("utf-8")
823+
// Same contract: a lossy decode is a partial view.
824+
const lossyDecode = !Buffer.from(rawContent).equals(rawBuffer)
772825

773826
// Handle line ranges if specified
827+
// S4b follow-up (#46 / epic #1375): a line-range read returns only the requested
828+
// ranges, and a slice truncated to DEFAULT_LINE_LIMIT returns only the head of
829+
// the file — record such observations as partial so the write guard denies a
830+
// full-file update built on them.
831+
let readComplete = false
774832
let content: string
775833
if (entry.lineRanges && entry.lineRanges.length > 0) {
776834
const lines = rawContent.split("\n")
@@ -790,15 +848,36 @@ export class ReadFileTool extends BaseTool<"read_file"> {
790848
// Read with default limits using slice mode
791849
const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT)
792850
content = result.content
851+
readComplete = !result.wasTruncated && !result.hasClippedLines
793852
if (result.wasTruncated) {
794853
content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]`
854+
if (result.hasClippedLines) {
855+
// Both notices: the slice was truncated and a line inside it was
856+
// clipped.
857+
content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]`
858+
}
859+
} else if (result.hasClippedLines) {
860+
content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]`
795861
}
796862
}
797863

798864
results.push(`File: ${relPath}\n${content}`)
799865

800866
// Track file in context
801867
await task.fileContextTracker.trackFileContext(relPath, "read_tool")
868+
869+
// A2 (plan #33 / epic #1375): mirror the native path — record the observed
870+
// on-disk version so legacy-format reads also feed the future write guard.
871+
// Observe only when the pre-read and post-read tokens match (a mutation between
872+
// them means the returned content is not the on-disk state). A stat failure
873+
// leaves the target unobserved and never fails the read.
874+
const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined)
875+
if (preReadStats && postReadStats) {
876+
const preReadToken = versionTokenOfStat(preReadStats)
877+
if (preReadToken === versionTokenOfStat(postReadStats)) {
878+
task.observationRegistry.observe(fullPath, preReadToken, readComplete && !lossyDecode)
879+
}
880+
}
802881
} catch (error) {
803882
const errorMsg = error instanceof Error ? error.message : String(error)
804883
results.push(`File: ${relPath}\nError: ${errorMsg}`)

0 commit comments

Comments
 (0)