Implement closed-loop workspace follow-ups
This commit is contained in:
@@ -17,6 +17,7 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval
|
||||
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
|
||||
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
|
||||
- Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`.
|
||||
- `list_dir` is shallow by default, but collapses a non-symlink single-child directory chain (bounded depth) to the first branch point and explains that expansion in its output; recursive listings retain normal tree traversal.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
||||
+39
-2
@@ -127,6 +127,33 @@ class ListDirTool(
|
||||
}
|
||||
|
||||
private fun walk(root: Path, recursive: Boolean, request: ToolRequest): ToolResult {
|
||||
var listedRoot = root
|
||||
var listing = collect(listedRoot, recursive)
|
||||
val descended = ArrayList<String>()
|
||||
// Do not follow symlinks: a symlink chain can point back to an ancestor outside the walk's
|
||||
// normal cycle protections. A real directory chain is capped as a second guard against a
|
||||
// pathological generated tree.
|
||||
while (!recursive && descended.size < MAX_AUTO_DESCENT_DEPTH) {
|
||||
val only = listing.entries.singleOrNull() ?: break
|
||||
val child = listedRoot.resolve(only.removeSuffix("/"))
|
||||
if (!only.endsWith('/') || Files.isSymbolicLink(child) || !Files.isDirectory(child)) break
|
||||
descended += only.removeSuffix("/")
|
||||
listedRoot = child
|
||||
listing = collect(listedRoot, recursive = false)
|
||||
}
|
||||
return ToolResult.Success(
|
||||
request.invocationId,
|
||||
output = render(listedRoot, listing.entries, listing.truncated, listing.ignoredCount, descended),
|
||||
)
|
||||
}
|
||||
|
||||
private data class Listing(
|
||||
val entries: List<String>,
|
||||
val truncated: Boolean,
|
||||
val ignoredCount: Int,
|
||||
)
|
||||
|
||||
private fun collect(root: Path, recursive: Boolean): Listing {
|
||||
val matcher = GitignoreMatcher(workingDir ?: root, root)
|
||||
// The caller explicitly targeted `root` — if a rule already covers it (e.g. an entire
|
||||
// subtree gitignored as scaffold output), don't let that same rule keep pruning everything
|
||||
@@ -166,7 +193,7 @@ class ListDirTool(
|
||||
override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE
|
||||
})
|
||||
out.sort()
|
||||
return ToolResult.Success(request.invocationId, output = render(root, out, truncated, ignoredCount))
|
||||
return Listing(out, truncated, ignoredCount)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,8 +203,17 @@ class ListDirTool(
|
||||
* gitignore-pruning are surfaced here too, so a shorter-than-expected listing isn't mistaken for
|
||||
* the real tree.
|
||||
*/
|
||||
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
|
||||
private fun render(
|
||||
root: Path,
|
||||
entries: List<String>,
|
||||
truncated: Boolean,
|
||||
ignoredCount: Int,
|
||||
descended: List<String>,
|
||||
): String {
|
||||
val notes = buildList {
|
||||
if (descended.isNotEmpty()) {
|
||||
add("auto-descended ${descended.joinToString(" → ")} (single child at each level); listing shown is $root")
|
||||
}
|
||||
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
|
||||
if (ignoredCount > 0) {
|
||||
add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
@@ -240,6 +276,7 @@ class ListDirTool(
|
||||
|
||||
private companion object {
|
||||
const val MAX_ENTRIES = 400
|
||||
const val MAX_AUTO_DESCENT_DEPTH = 32
|
||||
const val MAX_SIMILAR_SUGGESTIONS = 3
|
||||
const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
|
||||
const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
|
||||
|
||||
+19
-1
@@ -49,9 +49,10 @@ class ListDirToolTest {
|
||||
fun `default is shallow — a bare list_dir does not descend`(): Unit = runBlocking {
|
||||
// Regression: a recursive default buried top-level answers ("does frontend/ exist?") under a
|
||||
// 400-entry alphabetical flood and drove models to re-issue the same list_dir 2-3x. A bare
|
||||
// call (no recursive param) must now list only immediate children.
|
||||
// call (no recursive param) must not descend a directory that already has a meaningful choice.
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") }
|
||||
Files.writeString(root.resolve("README.md"), "root file")
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request()) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/"), out)
|
||||
@@ -75,9 +76,26 @@ class ListDirToolTest {
|
||||
fun `non-recursive lists only immediate children`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") }
|
||||
Files.writeString(root.resolve("README.md"), "root file")
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/"), out)
|
||||
assertFalse(out.contains("deep"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-recursive listing auto-descends a single-child directory chain`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("core/kernel/src/main"))
|
||||
Files.writeString(root.resolve("core/kernel/src/main/App.kt"), "class App")
|
||||
Files.createDirectories(root.resolve("core/kernel/src/main/resources"))
|
||||
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output
|
||||
|
||||
assertTrue(out.contains("<path>${root.resolve("core/kernel/src/main")}</path>"), out)
|
||||
assertTrue(out.contains("App.kt"), out)
|
||||
assertTrue(out.contains("resources/"), out)
|
||||
assertTrue(out.contains("auto-descended core → kernel → src → main"), out)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user