From c849fd9921a5961d838e3dfdc4efa34674a8bf73 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 7 Jul 2026 14:59:09 +0400 Subject: [PATCH] feat(tools): cap file_read output so one read can't flood L2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) --- .../tools/filesystem/FileReadTool.kt | 48 ++++++++++++++--- .../tools/filesystem/FileReadToolTest.kt | 51 +++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 526d8a7b..1e63e438 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -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 + } } diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt index 69cd4cb1..b477202a 100644 --- a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt @@ -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()