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:
+41
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+51
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user