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.
This commit is contained in:
2026-06-14 03:13:22 +04:00
parent 1de3e94990
commit a38ac1b3c9
2 changed files with 64 additions and 19 deletions
@@ -26,6 +26,8 @@ import java.nio.file.Files
import java.nio.file.InvalidPathException import java.nio.file.InvalidPathException
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
class FileReadTool( class FileReadTool(
private val allowedPaths: Set<Path> = emptySet(), private val allowedPaths: Set<Path> = emptySet(),
@@ -45,13 +47,18 @@ class FileReadTool(
} }
override val name: String = "file_read" 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 { override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object") put("type", "object")
putJsonObject("properties") { putJsonObject("properties") {
putJsonObject("path") { putJsonObject("path") {
put("type", "string") 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") { putJsonObject("start_line") {
put("type", "integer") put("type", "integer")
@@ -115,29 +122,52 @@ class FileReadTool(
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull() val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
if (!Files.exists(path)) if (!Files.exists(path))
// Recoverable: the model often probes for files that may not exist (e.g. a // 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. // speculative __init__.py). Feed the miss back so it can adapt, don't kill the stage.
return@withContext ToolResult.Failure( return@withContext ToolResult.Failure(
invocationId = request.invocationId, invocationId = request.invocationId,
reason = "File not found: $pathString", reason = "File not found: $pathString",
recoverable = true, recoverable = true,
) )
runCatching { if (Files.isDirectory(path)) {
val lines = Files.readAllLines(path) listDir(path, request)
val start = ((startLine ?: 1) - 1).coerceAtLeast(0) } else {
val end = (endLine ?: lines.size).coerceAtMost(lines.size) readFile(path, startLine, endLine, request)
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 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,
)
}
} }
@@ -112,6 +112,21 @@ class FileReadToolTest {
assertTrue(failure.reason.contains("is not in the allowed list")) 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 @Test
fun `outputCompressor strips blank lines and leading whitespace`() { fun `outputCompressor strips blank lines and leading whitespace`() {
val tool = FileReadTool() val tool = FileReadTool()