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