fix(orchestration): break the no-op-write loop + un-rot retry feedback
A stage (configure_ethos_theme) looped forever re-writing an identical
postcss.config.js: file_write returned "written successfully" on a byte-
identical write, and the retry-feedback entry that says WHY it failed got
truncated out of context every turn, so the model cold-started on the same
wrong idea. CAS hashes leaked into its face from two sides too.
- SandboxedToolExecutor: a write whose result is byte-identical to disk now
returns "No change: … nothing was written" (+ noop=true) instead of a false
success signal. Uses the pre-image hashes already captured for reversibility.
- DefaultContextPackBuilder: pin "retryFeedback" in neverDropSourceTypes so the
failure reason + already-written-files list survives truncation.
- ContextFeedback: drop the opaque "— CAS <hash>" from the retry entry; path only.
- tool_output withheld until the session actually spills over-cap output to CAS
(StageConfig.ALWAYS_AVAILABLE_READ_TOOLS -> on-demand via sessionHasSpilledOutput),
so a hash-eating tool isn't advertised to every stage that never spills.
- Wrap 10 long lines in kernel to bring detekt back under maxIssues (99 -> 89).
Tests: infrastructure:tools (+2 no-op cases), core:{transitions,context,kernel} green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+28
-1
@@ -95,7 +95,7 @@ class SandboxedToolExecutor(
|
||||
// reconstruct: pre/post-image hashes (reversibility) and research-source markers.
|
||||
emitResearchSourceEvents(sessionId, request.stageId, result)
|
||||
emitFileMutations(sessionId, invocationId, affectedPaths, preImages)
|
||||
result
|
||||
reframeIfNoOpWrite(result, affectedPaths, preImages)
|
||||
}
|
||||
|
||||
is ToolResult.Failure -> {
|
||||
@@ -158,6 +158,33 @@ class SandboxedToolExecutor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A write whose result is byte-identical to what was already on disk is a no-op, but the delegate
|
||||
* still reports "written successfully" — a false progress signal that traps looping agents (they
|
||||
* re-write the same content, see success, and never learn nothing changed). Rewrite the receipt to
|
||||
* the truth. Only fires when EVERY affected path pre-existed with the same content, so a partial
|
||||
* change (one file touched, one identical) is still reported as a real write.
|
||||
* ponytail: hash-compares via the CAS, so only active when artifactStore is wired; else pass-through.
|
||||
*/
|
||||
private suspend fun reframeIfNoOpWrite(
|
||||
result: ToolResult.Success,
|
||||
affectedPaths: Set<Path>,
|
||||
preImages: Map<Path, PreImage>,
|
||||
): ToolResult.Success {
|
||||
if (artifactStore == null || affectedPaths.isEmpty()) return result
|
||||
val unchanged = affectedPaths.all { path ->
|
||||
val pre = preImages[path]
|
||||
pre?.existed == true && pre.hash != null && pre.hash == storeBytes(path)
|
||||
}
|
||||
if (!unchanged) return result
|
||||
val paths = affectedPaths.joinToString(", ") { it.toString() }
|
||||
return result.copy(
|
||||
output = "No change: $paths already contained this exact content; nothing was written. " +
|
||||
"Do not repeat this write — make a different change or complete the stage.",
|
||||
metadata = result.metadata + ("noop" to "true"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- event emission ---
|
||||
|
||||
private suspend fun emitStarted(
|
||||
|
||||
+33
@@ -115,6 +115,39 @@ class SandboxedToolExecutorFileMutationTest {
|
||||
assertEquals("HELLO", store.get(ArtifactId(fw.postImageHash!!))!!.toString(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing identical content reframes the success message as a no-op`(): Unit = runBlocking {
|
||||
val dir = Files.createTempDirectory("sbx-noop").toRealPath()
|
||||
val target = dir.resolve("f.txt")
|
||||
Files.writeString(target, "SAME")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(dir))
|
||||
val store = FakeArtifactStore()
|
||||
val events = CapturingEventStore()
|
||||
|
||||
val result = executor(tool, store, events).execute(writeRequest(target.toString(), "SAME"))
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val success = result as ToolResult.Success
|
||||
assertTrue(success.output.startsWith("No change:"), success.output)
|
||||
assertEquals("true", success.metadata["noop"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing content keeps the normal success message`(): Unit = runBlocking {
|
||||
val dir = Files.createTempDirectory("sbx-changed").toRealPath()
|
||||
val target = dir.resolve("f.txt")
|
||||
Files.writeString(target, "OLD")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(dir))
|
||||
val store = FakeArtifactStore()
|
||||
val events = CapturingEventStore()
|
||||
|
||||
val result = executor(tool, store, events).execute(writeRequest(target.toString(), "NEW"))
|
||||
|
||||
val success = result as ToolResult.Success
|
||||
assertTrue(!success.output.startsWith("No change:"), success.output)
|
||||
assertNull(success.metadata["noop"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no artifact store means no FileWrittenEvent (backward compatible)`(): Unit = runBlocking {
|
||||
val dir = Files.createTempDirectory("sbx-nostore").toRealPath()
|
||||
|
||||
Reference in New Issue
Block a user