feat(recovery): route failed write-less stages to recovery instead of futile retry
Adds the failure-ticket + recovery-routing mechanism: a deterministic gate->capability table gates whether a stage has agency to fix its own failure, opens a FailureTicketOpenedEvent when it doesn't, and routes to a metadata role=recovery stage (per-stage budget, cap 2, not reset by TransitionExecuted) instead of retrying in place. Extends salvage decisions with a RECOVER option so the review-gate judge can also route to recovery, unifies deterministic-gate and review-gate routing through routeToRecovery(), and has ExecutionPlanCompiler synthesize a write-capable recovery stage + edge for freestyle plans. Read-only tools (file_read, list_dir) are now always available on any tool-granting stage so a recovery stage can inspect the write-less stage's failure without flooding context via shell ls -R.
This commit is contained in:
+11
-7
@@ -27,11 +27,15 @@ import kotlin.io.path.name
|
||||
import kotlin.io.path.readText
|
||||
|
||||
/**
|
||||
* Recursive, `.gitignore`-aware directory listing — the affordance weak local models should reach
|
||||
* for instead of shell `ls -R`, which dumps `node_modules`/`build`/`dist` and drowns a small model
|
||||
* in noise. Read-only (Tier T1, FILE_READ): it shares [FileReadTool]'s path jail and workspace
|
||||
* anchor. `.gitignore` is honoured for *enumeration only* — the mutation tools deliberately do NOT
|
||||
* respect it (you legitimately write to ignored paths like `.env`/`dist`).
|
||||
* `.gitignore`-aware directory listing — the affordance weak local models should reach for instead
|
||||
* of shell `ls -R`, which dumps `node_modules`/`build`/`dist` and drowns a small model in noise.
|
||||
* Shallow by default (`recursive=false`): a bare `list_dir .` returns the top-level entries, which
|
||||
* is what the common question ("what's at the root / does `frontend/` exist?") actually wants — a
|
||||
* recursive default buried that answer under an alphabetical 400-entry `docs/` subtree flood and
|
||||
* drove models to re-issue the same call 2-3x (observed: discovery looping on `list_dir .`). Pass
|
||||
* `recursive:true` to descend. Read-only (Tier T1, FILE_READ): it shares [FileReadTool]'s path jail
|
||||
* and workspace anchor. `.gitignore` is honoured for *enumeration only* — the mutation tools
|
||||
* deliberately do NOT respect it (you legitimately write to ignored paths like `.env`/`dist`).
|
||||
*/
|
||||
class ListDirTool(
|
||||
private val allowedPaths: Set<Path> = emptySet(),
|
||||
@@ -60,7 +64,7 @@ class ListDirTool(
|
||||
}
|
||||
putJsonObject("recursive") {
|
||||
put("type", "boolean")
|
||||
put("description", "Descend into sub-directories (gitignored subtrees are pruned). Default true.")
|
||||
put("description", "Descend into sub-directories (gitignored subtrees are pruned). Default false.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray {})
|
||||
@@ -90,7 +94,7 @@ class ListDirTool(
|
||||
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
|
||||
}
|
||||
val pathString = (request.parameters["path"] as? String) ?: "."
|
||||
val recursive = request.parameters["recursive"]?.toString()?.toBoolean() ?: true
|
||||
val recursive = request.parameters["recursive"]?.toString()?.toBoolean() ?: false
|
||||
val root = resolvePath(pathString)
|
||||
when {
|
||||
!Files.exists(root) ->
|
||||
|
||||
+14
-1
@@ -36,7 +36,7 @@ class ListDirToolTest {
|
||||
Files.writeString(root.resolve("package.json"), "{}")
|
||||
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request()) as ToolResult.Success).output
|
||||
val out = (tool.execute(request(recursive = true)) as ToolResult.Success).output
|
||||
|
||||
assertTrue(out.contains("src/main.ts"), out)
|
||||
assertTrue(out.contains("package.json"), out)
|
||||
@@ -45,6 +45,19 @@ class ListDirToolTest {
|
||||
assertFalse(out.contains("debug.log"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
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.
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") }
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request()) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/"), out)
|
||||
assertFalse(out.contains("deep"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-recursive lists only immediate children`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
|
||||
Reference in New Issue
Block a user