From a38ac1b3c9dbf3bb7d025856d015be8956bff054 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 14 Jun 2026 03:13:22 +0400 Subject: [PATCH] feat(tools): file_read lists directory entries When given a directory path, file_read now returns its entries (sorted, sub-directories suffixed '/') instead of failing. The tool description and path-param schema advertise this so the model discovers it rather than giving up after a missing-file probe. Generalises file_read into a 'read this path' primitive; the existing PathJail/Plane-2 containment already covers directories. --- .../tools/filesystem/FileReadTool.kt | 68 +++++++++++++------ .../tools/filesystem/FileReadToolTest.kt | 15 ++++ 2 files changed, 64 insertions(+), 19 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 61561b90..6aca46fa 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 @@ -26,6 +26,8 @@ import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path import java.nio.file.Paths +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name class FileReadTool( private val allowedPaths: Set = emptySet(), @@ -45,13 +47,18 @@ class FileReadTool( } override val name: String = "file_read" - override val description: String = "Read content from a file at the specified path" + override val description: String = + "Read a file's contents, or list a directory's entries, at the specified path" override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { putJsonObject("path") { put("type", "string") - put("description", "Relative path to the file to read") + put( + "description", + "Relative path. A file path returns the file's contents; a directory path " + + "returns its entries, one per line, with sub-directories suffixed '/'.", + ) } putJsonObject("start_line") { put("type", "integer") @@ -115,29 +122,52 @@ class FileReadTool( val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull() if (!Files.exists(path)) - // Recoverable: the model often probes for files that may not exist (e.g. a - // speculative __init__.py). Feed the miss back so it can adapt, don't kill the stage. + // Recoverable: the model often probes for files that may not exist (e.g. a + // speculative __init__.py). Feed the miss back so it can adapt, don't kill the stage. return@withContext ToolResult.Failure( invocationId = request.invocationId, reason = "File not found: $pathString", recoverable = true, ) - runCatching { - 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") - ToolResult.Success( - invocationId = request.invocationId, - output = content, - ) - }.getOrElse { - ToolResult.Failure( - invocationId = request.invocationId, - reason = "Failed to read file: ${it.message}", - recoverable = false, - ) + if (Files.isDirectory(path)) { + listDir(path, request) + } else { + readFile(path, startLine, endLine, request) } } + + private fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching { + 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") + ToolResult.Success( + invocationId = request.invocationId, + output = content, + ) + }.getOrElse { + ToolResult.Failure( + invocationId = request.invocationId, + reason = "Failed to read file: ${it.message}", + recoverable = false, + ) + } + + private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching { + val entries = path.listDirectoryEntries().sortedBy { it.name } + val output = entries.joinToString("\n") { entry -> + if (Files.isDirectory(entry)) "${entry.name}/" else entry.name + } + ToolResult.Success( + invocationId = request.invocationId, + output = output, + ) + }.getOrElse { + ToolResult.Failure( + invocationId = request.invocationId, + reason = "Failed to list dir: ${it.message}", + recoverable = false, + ) + } } 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 02eca108..69cd4cb1 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 @@ -112,6 +112,21 @@ class FileReadToolTest { assertTrue(failure.reason.contains("is not in the allowed list")) } + @Test + fun `execute lists directory entries sorted with sub-directory marker`(): Unit = runBlocking { + val tempDir = Files.createTempDirectory("test_listdir") + Files.writeString(tempDir.resolve("README.md"), "x") + Files.writeString(tempDir.resolve("build.gradle"), "x") + Files.createDirectory(tempDir.resolve("src")) + + val tool = FileReadTool(allowedPaths = setOf(tempDir)) + val result = tool.execute(createRequest(tempDir.toString())) + + assertTrue(result is ToolResult.Success) + // Sorted by name; directories suffixed with '/' so the model can navigate. + assertEquals("README.md\nbuild.gradle\nsrc/", (result as ToolResult.Success).output) + } + @Test fun `outputCompressor strips blank lines and leading whitespace`() { val tool = FileReadTool()