fix(tools): tolerate indent drift + content alias in file_edit; concrete scope-widen remedy

file_edit failed en masse for small models on two ergonomics traps:
- replace() rejected calls that sent `content` (the append param) instead of
  `replacement` — intent unambiguous; now accepted as an alias.
- exact-string match died on leading-whitespace drift (model can't reproduce
  indentation). Added whitespace-flexible line matching + replacement reindent,
  wired into both the pre-exec validation gate and replace().

Also made the WRITE_SCOPE / PATH_OUTSIDE_MANIFEST block messages emit a literal
copy-pasteable task_update(id=..., affected_paths=[...]) call and warn against
action=block — the exact wrong turn models kept taking (18x in one session).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rdo9fe7SujNVeyZA8YkpkD
This commit is contained in:
2026-07-21 00:25:54 +04:00
parent 9d6a0ce4ee
commit 1acb5cc8ff
5 changed files with 127 additions and 21 deletions
@@ -78,8 +78,12 @@ class ManifestContainmentRule : ToolCallRule {
val remedy = if (taskScope.isEmpty()) {
" Claim a task whose affected_paths cover this path (task_update), then retry."
} else {
" If this path is genuinely part of the task, widen its affected_paths via " +
"task_update (note why), then retry."
val taskId = input.session.activeTask?.taskId
val widened = (taskScope + raw).joinToString(", ") { "\"$it\"" }
" If this path is genuinely part of the task, widen scope with this exact call — " +
"task_update(id=\"$taskId\", affected_paths=[$widened], note=\"why $raw is in " +
"scope\") — then retry the write. affected_paths REPLACES the old list, so keep " +
"the existing paths. Do NOT use action=block/unblock; that does not change scope."
}
issues += ValidationIssue(
code = "PATH_OUTSIDE_MANIFEST",
@@ -52,11 +52,15 @@ class WriteScopeRule : ToolCallRule {
)
if (!inScope) {
val widened = (active.scope + raw).joinToString(", ") { "\"$it\"" }
issues += ValidationIssue(
code = RULE_CODE,
message = "Tool '${input.request.toolName}' writes '$raw', outside claimed task " +
"${active.taskId}'s affected_paths (${active.scope}). If this change is needed, " +
"add its path via task_update affected_paths (note why), then retry.",
"widen the scope with this exact call — task_update(id=\"${active.taskId}\", " +
"affected_paths=[$widened], note=\"why $raw is in scope\") — then retry the write. " +
"The affected_paths list REPLACES the old one, so include the existing paths shown " +
"above. Do NOT use action=block/unblock; that does not change scope.",
severity = ValidationSeverity.ERROR,
)
disposition = maxAction(disposition, RiskAction.BLOCK)
@@ -59,6 +59,7 @@ class WriteScopeRuleTest {
assertEquals(RiskAction.BLOCK, r.disposition)
assertEquals("WRITE_SCOPE", r.issues.single().code)
assertTrue(r.issues.single().message.contains("auth-2"))
assertTrue(r.issues.single().message.contains("task_update affected_paths"))
assertTrue(r.issues.single().message.contains("task_update(id=\"auth-2\""))
assertTrue(r.issues.single().message.contains("affected_paths=["))
}
}