feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events

- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
  as intent-holder, holds initial intent, reconciles cross-file contract disputes)
  with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
This commit is contained in:
2026-07-11 23:56:52 +04:00
parent 3d5e05c1fb
commit 15248cae8a
55 changed files with 1932 additions and 231 deletions
@@ -79,7 +79,30 @@ class FileEditTool(
add(JsonPrimitive("path"))
add(JsonPrimitive("operation"))
})
// Per-operation requirements. `required` above can't express these (they depend on the
// chosen operation), so a plain schema lets a constrained decoder finish a 'replace' call
// with no target/replacement — a schema-valid but useless edit the runtime then has to
// reject. The if/then conditionals make the operand params required once the operation is
// fixed, so a schema-aware decoder emits them.
put("allOf", buildJsonArray {
add(conditionalRequirement("replace", "target", "replacement"))
add(conditionalRequirement("append", "content"))
add(conditionalRequirement("patch", "patch"))
})
}
private fun conditionalRequirement(operation: String, vararg required: String): JsonObject =
buildJsonObject {
putJsonObject("if") {
putJsonObject("properties") {
putJsonObject("operation") { put("const", operation) }
}
put("required", buildJsonArray { add(JsonPrimitive("operation")) })
}
putJsonObject("then") {
put("required", buildJsonArray { required.forEach { add(JsonPrimitive(it)) } })
}
}
override val tier: Tier = Tier.T3
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
@@ -232,27 +255,76 @@ class FileEditTool(
val currentContent = Files.readString(path)
val occurrences = currentContent.split(target).size - 1
return if (occurrences == 1) {
val newContent = currentContent.replace(target, replacement)
Files.writeString(path, newContent)
ToolResult.Success(
return when (occurrences) {
1 -> {
val newContent = currentContent.replace(target, replacement)
Files.writeString(path, newContent)
ToolResult.Success(
invocationId = request.invocationId,
output = "Target replaced in $pathString",
)
}
// Don't echo the (often whole-file) target back into context — it bloats the turn and
// teaches nothing. Instead point at the closest actual line, since a miss is almost
// always whitespace/content drift the model can correct from a nudge.
0 -> ToolResult.Failure(
invocationId = request.invocationId,
output = "Target replaced in $pathString",
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = if (occurrences == 0) {
"Target '$target' not found in $pathString"
} else {
"Target '$target' found $occurrences times, expected exactly once"
reason = buildString {
append("Target not found in $pathString")
nearestLine(currentContent, target)?.let {
append(". Did you mean (whitespace/content may differ): $it ?")
}
},
// Recoverable: the model can adjust the target string and retry.
recoverable = true,
)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Target found $occurrences times in $pathString, expected exactly once — " +
"extend it with surrounding lines to make it unique.",
recoverable = true,
)
}
}
/**
* Best-effort "did you mean" for a not-found replace target: anchor on the target's first
* non-blank line and return the file line most similar to it (levenshtein ratio), or null when
* nothing is close enough to be a useful suggestion. Mirrors ListDirTool's fuzzy directory hint.
*/
private fun nearestLine(content: String, target: String): String? {
val anchor = target.lineSequence().firstOrNull { it.isNotBlank() }?.trim()?.take(MAX_CMP)
if (anchor == null || anchor.length < MIN_ANCHOR) return null
return content.lineSequence()
.filter { it.isNotBlank() }
.map { it.trim() to similarity(anchor, it.trim().take(MAX_CMP)) }
.maxByOrNull { it.second }
?.takeIf { it.second >= MIN_SIMILARITY }
?.first
}
private fun similarity(a: String, b: String): Double {
val maxLen = maxOf(a.length, b.length)
return if (maxLen == 0) 1.0 else 1.0 - levenshtein(a, b).toDouble() / maxLen
}
private fun levenshtein(a: String, b: String): Int {
val dp = IntArray(b.length + 1) { it }
for (i in 1..a.length) {
var prev = dp[0]
dp[0] = i
for (j in 1..b.length) {
val tmp = dp[j]
dp[j] = minOf(
dp[j] + 1,
dp[j - 1] + 1,
prev + if (a[i - 1].lowercaseChar() == b[j - 1].lowercaseChar()) 0 else 1,
)
prev = tmp
}
}
return dp[b.length]
}
private fun patch(path: Path, pathString: String, request: ToolRequest): ToolResult {
val patchContent = request.parameters["patch"] as String
val parentDir = path.parent ?: Paths.get(".")
@@ -312,4 +384,10 @@ class FileEditTool(
recoverable = false,
)
}
private companion object {
const val MIN_ANCHOR = 3
const val MAX_CMP = 400
const val MIN_SIMILARITY = 0.5
}
}
@@ -93,7 +93,10 @@ class FileReadTool(
return runCatching {
val path = resolvePath(pathString)
if (!PathJail.isContained(path, allowedPaths))
// Widen the jail with any operator-approved out-of-workspace paths carried on the request
// (OutsidePathAccessGrantedEvent). Reads may escape once approved; writes never see this.
val effectiveRoots = allowedPaths + request.grantedPaths.map { Paths.get(it) }
if (!PathJail.isContained(path, effectiveRoots))
ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
else
ValidationResult.Valid
@@ -78,7 +78,9 @@ class ListDirTool(
val pathString = (request.parameters["path"] as? String) ?: "."
return runCatching {
val path = resolvePath(pathString)
if (!PathJail.isContained(path, allowedPaths))
// Operator-approved out-of-workspace paths (OutsidePathAccessGrantedEvent) widen the jail.
val effectiveRoots = allowedPaths + request.grantedPaths.map { Paths.get(it) }
if (!PathJail.isContained(path, effectiveRoots))
ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
else ValidationResult.Valid
}.getOrElse {
@@ -179,6 +179,28 @@ class FileEditToolTest {
assertTrue(failure.reason.contains("not found"))
}
@Test
fun `execute replace not-found suggests nearest line`(): Unit = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace_suggest")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "import React from 'react';\nconst greeting = 'hello world';\n")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "replace",
"path" to filePath.toString(),
// whitespace/quote drift from the real line — the classic near-miss
"target" to "const greeting = \"hello world\"",
"replacement" to "const greeting = 'hi';",
),
)
val failure = tool.execute(request) as ToolResult.Failure
assertTrue(failure.reason.contains("not found"))
assertTrue(failure.reason.contains("Did you mean"))
assertTrue(failure.reason.contains("const greeting = 'hello world';"))
}
@Test
fun `execute replace failure multiple matches`(): Unit = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace_fail_multi")
@@ -49,6 +49,21 @@ class FileReadToolTest {
assertTrue((result as ValidationResult.Invalid).reason.contains("not in the allowed list"))
}
@Test
fun `validateRequest admits an out-of-jail path carried on request grantedPaths`(): Unit = runBlocking {
// A file the static jail does NOT cover (an unrelated temp dir), mirroring an out-of-workspace
// reference the operator approved. Without the grant it's rejected; with it, admitted.
val jailDir = Files.createTempDirectory("jail")
val outside = Files.createTempFile("outside_ref", ".md")
val tool = FileReadTool(allowedPaths = setOf(jailDir))
val ungranted = createRequest(outside.toString())
assertTrue(tool.validateRequest(ungranted) is ValidationResult.Invalid)
val granted = createRequest(outside.toString()).copy(grantedPaths = setOf(outside.toString()))
assertEquals(ValidationResult.Valid, tool.validateRequest(granted))
}
@Test
fun `validateRequest returns Invalid for missing path parameter`(): Unit = runBlocking {
val tool = FileReadTool()