Implement closed-loop workspace follow-ups

This commit is contained in:
2026-07-15 23:48:12 +04:00
parent 3a48ecd24f
commit ed7efb6072
51 changed files with 702 additions and 54 deletions
+1
View File
@@ -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
@@ -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
@@ -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)
}
}