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:
2026-07-08 10:28:53 +04:00
parent d6bada6f10
commit f51a8dada4
21 changed files with 325 additions and 29 deletions
@@ -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) ->
@@ -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")
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.GenerationConfig
import com.correx.core.transitions.graph.BuildExpectation
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
@@ -38,6 +39,16 @@ private const val RECOVERY_PROMPT =
// leaves ample headroom for completion.)
private const val DEFAULT_STAGE_TOKEN_BUDGET = 16384
// A compiled freestyle stage must also lift its inference completion cap off StageConfig's 2048
// default, or the model is truncated (finishReason=length) mid-artifact — and a degenerating local
// model burns the whole 2048 on garbage (e.g. a `<|channel>thought` repetition loop) before it can
// stop. Mirror the static TomlWorkflowLoader path: pin the completion cap to the stage token budget.
private val DEFAULT_STAGE_GENERATION = GenerationConfig(
temperature = 0.7,
topP = 1.0,
maxTokens = DEFAULT_STAGE_TOKEN_BUDGET,
)
class ExecutionPlanCompiler(
private val registry: ArtifactKindRegistry,
// Names of every registered tool. A stage that references a tool the runtime can't resolve
@@ -117,6 +128,7 @@ class ExecutionPlanCompiler(
autoBuildGate = s.id == autoGateStageId,
semanticReview = s.semanticReview,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = DEFAULT_STAGE_GENERATION,
metadata = mapOf("promptInline" to s.prompt),
)
}
@@ -134,6 +146,7 @@ class ExecutionPlanCompiler(
StageId(RECOVERY_STAGE) to StageConfig(
allowedTools = setOf("file_write", "shell"),
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = DEFAULT_STAGE_GENERATION,
metadata = mapOf("role" to "recovery", "promptInline" to RECOVERY_PROMPT),
)
}