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:
+33
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user