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.Path
import java.nio.file.Paths
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
class FileReadTool(
private val allowedPaths: Set<Path> = 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")
@@ -123,7 +130,14 @@ class FileReadTool(
recoverable = true,
)
runCatching {
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)
@@ -139,5 +153,21 @@ class FileReadTool(
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"))
}
@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()