feat(toolintent): stale-write gate

Block writing a file whose on-disk content changed since the session read it — a
concurrent/external edit the agent's view doesn't reflect. file_read records a
content hash on whole-file reads; the orchestrator's completion path now carries
the tool's structuredOutput (it previously dropped it, unlike SandboxedToolExecutor
— the two paths were inconsistent), so SessionContext.readHashes folds it.
StaleWriteRule compares the current hash (WorldProbe.contentHash) against the
read-time hash and BLOCKs a mismatch, telling the agent to re-read first. Files the
session wrote itself are excluded, so its own edits never look stale; partial reads
set no baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 06:15:15 +00:00
parent 4159361690
commit 6a779861c2
8 changed files with 183 additions and 1 deletions
@@ -142,9 +142,13 @@ class FileReadTool(
val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
val end = (endLine ?: lines.size).coerceAtMost(lines.size)
val content = lines.subList(start, end).joinToString("\n")
// Record a content hash only for a whole-file read, so the stale-write gate baselines against
// what the agent actually saw; a partial read establishes no baseline.
val metadata = if (startLine == null && endLine == null) mapOf("contentHash" to sha256(path)) else emptyMap()
ToolResult.Success(
invocationId = request.invocationId,
output = content,
metadata = metadata,
)
}.getOrElse {
ToolResult.Failure(
@@ -154,6 +158,10 @@ class FileReadTool(
)
}
private fun sha256(path: Path): String =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))
.joinToString("") { "%02x".format(it) }
private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
val entries = path.listDirectoryEntries().sortedBy { it.name }
val output = entries.joinToString("\n") { entry ->