feat(tools): cap file_read output so one read can't flood L2

On a real (large) codebase, a file_read of a big file dumped the whole
body into L2 uncapped, evicting the files the stage actually needed —
the ContextTruncated we saw on real-repo runs. Bound it:

- readFile: auto-truncate a read-to-EOF past MAX_READ_LINES (400) with a
  note pointing at start_line/end_line; hard char backstop (20k) for
  very long lines. An explicit end_line is deliberate paging, honoured
  in full. No contentHash baseline on a truncated view, so the
  stale-write gate forces a real read before an edit.
- listDir (single-level): cap at LIST_MAX_ENTRIES (400) with a note.
  (ListDirTool's recursive walk was already capped.)
This commit is contained in:
2026-07-07 14:59:09 +04:00
parent 82c55ec9c7
commit c849fd9921
2 changed files with 92 additions and 7 deletions
@@ -141,13 +141,32 @@ class FileReadTool(
val lines = Files.readAllLines(path)
val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
val end = (endLine ?: lines.size).coerceAtMost(lines.size)
val content = lines.subList(start, end).joinToString("\n")
// Record a content hash only for a whole-file read, so the stale-write gate baselines against
// what the agent actually saw; a partial read establishes no baseline.
val metadata = if (startLine == null && endLine == null) mapOf("contentHash" to sha256(path)) else emptyMap()
val selected = lines.subList(start, end)
// Bound the read's context footprint. Only auto-truncate a read-to-EOF (no explicit end_line):
// an explicit range is deliberate paging and is honoured in full. This stops a single file_read
// of a large file (e.g. a doc) from flooding L2 and evicting the files the stage actually needs.
val lineCapped = endLine == null && selected.size > MAX_READ_LINES
var content = (if (lineCapped) selected.take(MAX_READ_LINES) else selected).joinToString("\n")
var note = if (lineCapped) {
"\n… truncated at $MAX_READ_LINES lines (file has ${lines.size}); " +
"pass start_line/end_line to read a specific range."
} else {
""
}
// Hard char backstop (very long lines can blow the budget under the line cap).
val charCapped = content.length > MAX_READ_CHARS
if (charCapped) {
content = content.take(MAX_READ_CHARS)
note = "\n… truncated at $MAX_READ_CHARS chars; pass start_line/end_line to page through the file."
}
// Record a content hash only for a WHOLE, UNTRUNCATED read, so the stale-write gate baselines
// against what the agent actually saw. A partial or truncated view establishes no baseline —
// the agent must read the relevant range before editing.
val sawWholeFile = startLine == null && endLine == null && !lineCapped && !charCapped
val metadata = if (sawWholeFile) mapOf("contentHash" to sha256(path)) else emptyMap()
ToolResult.Success(
invocationId = request.invocationId,
output = content,
output = content + note,
metadata = metadata,
)
}.getOrElse {
@@ -164,12 +183,18 @@ class FileReadTool(
private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
val entries = path.listDirectoryEntries().sortedBy { it.name }
val output = entries.joinToString("\n") { entry ->
val shown = entries.take(LIST_MAX_ENTRIES).joinToString("\n") { entry ->
if (Files.isDirectory(entry)) "${entry.name}/" else entry.name
}
val note =
if (entries.size > LIST_MAX_ENTRIES) {
"\n… truncated at $LIST_MAX_ENTRIES of ${entries.size} entries; list a sub-directory to narrow."
} else {
""
}
ToolResult.Success(
invocationId = request.invocationId,
output = output,
output = shown + note,
)
}.getOrElse {
ToolResult.Failure(
@@ -178,4 +203,13 @@ class FileReadTool(
recoverable = false,
)
}
private companion object {
// Bound a single read's context footprint so one file_read of a large file can't flood L2
// and evict the files the stage actually needs. A read-to-EOF past the line cap, or any read
// past the char backstop, is truncated with a note pointing at start_line/end_line paging.
const val MAX_READ_LINES = 400
const val MAX_READ_CHARS = 20_000
const val LIST_MAX_ENTRIES = 400
}
}
@@ -9,6 +9,7 @@ import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
@@ -127,6 +128,56 @@ class FileReadToolTest {
assertEquals("README.md\nbuild.gradle\nsrc/", (result as ToolResult.Success).output)
}
@Test
fun `execute truncates a large read-to-EOF and points at start_line paging`(): Unit = runBlocking {
val tempFile = Files.createTempFile("test_big", ".txt")
Files.writeString(tempFile, (1..1000).joinToString("\n") { "line $it" })
val tool = FileReadTool(allowedPaths = setOf(tempFile.parent))
val result = tool.execute(createRequest(tempFile.toString()))
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertTrue(success.output.startsWith("line 1\n"), "kept the head of the file")
assertTrue(success.output.contains("truncated at 400 lines (file has 1000)"), "carries a paging note")
assertTrue(success.output.contains("start_line/end_line"), "tells the model how to page")
// A truncated view establishes no stale-write baseline.
assertTrue(success.metadata["contentHash"] == null, "no whole-file hash on a truncated read")
}
@Test
fun `execute honours an explicit end_line without truncating`(): Unit = runBlocking {
val tempFile = Files.createTempFile("test_range", ".txt")
Files.writeString(tempFile, (1..1000).joinToString("\n") { "line $it" })
val tool = FileReadTool(allowedPaths = setOf(tempFile.parent))
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "file_read",
parameters = mapOf("path" to tempFile.toString(), "start_line" to 10, "end_line" to 600),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val output = (result as ToolResult.Success).output
assertFalse(output.contains("truncated"), "explicit range is deliberate paging, honoured in full")
assertTrue(output.startsWith("line 10\n") && output.endsWith("line 600"))
}
@Test
fun `execute records a content hash for a small whole-file read`(): Unit = runBlocking {
val tempFile = Files.createTempFile("test_hash", ".txt")
Files.writeString(tempFile, "short file\nsecond line")
val tool = FileReadTool(allowedPaths = setOf(tempFile.parent))
val result = tool.execute(createRequest(tempFile.toString()))
assertTrue(result is ToolResult.Success)
assertTrue((result as ToolResult.Success).metadata["contentHash"] != null)
}
@Test
fun `outputCompressor strips blank lines and leading whitespace`() {
val tool = FileReadTool()