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:
+92
-14
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
+22
@@ -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")
|
||||
|
||||
+15
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user