fix(chat): agent write_file/edit_file no longer return false success receipts (BUG-29) #100
Reference in New Issue
Block a user
Delete Branch "fix/receipts-agent-tools"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Root cause (BUG-29)
dispatch_toolin chat.el fed the model false success receipts on its two file-mutating agent tools:{"ok":true}without ever checkingfs_write's result. A write into a missing/unwritable directory (or any fs failure) still reported success.{"ok":true}even whenold_textwas not in the file —str_replacesilently no-ops — and itsfs_writewas also unchecked. The model was told the edit landed when the file was untouched.The model repeats these receipts to the user as fact, so every downstream claim built on them is a confabulation the soul itself manufactured.
The change
Implements Receipt Contract rule 1 (docs repo,
docs/specs/RECEIPT-CONTRACT-2026-07-22.md): a tool result must reflect what actually happened.fs_write's return (1 = all bytes written, 0 = fail); on failure return{"error":"write failed"}— same error-JSON shape the handler already uses ({"error":"file not found"}).old_text; verifyold_textis actually present (str_contains) before replacing ({"error":"old_text not found in file"}); check thefs_writeresult the same way.fs_readarms the runtime's one-shot binary send length — the exact mechanism that truncated the safety-contact response (#96). This is the same honest-write pattern that fix established in safety.el.Minimal diff, no new architecture: 26 insertions in the two handlers.
How to test (exact sandbox repro)
No HTTP route reaches
dispatch_toolwithout a live LLM (the agentic loop creates the only suspension blobs/api/sessions/{id}/approveand/tool_resultwill resume — verified empirically: both returnno pending tool/unknown session_idon a fresh soul). So the repro drivesdispatch_tooldirectly with a throwaway.elentry (not committed):Setup:
mkdir <SANDBOX>/locked && chmod 000 <SANDBOX>/locked; printf 'hello world\n' > <SANDBOX>/target.txt. Build with elb (--out=<builddir>pre-seeded withdist/elp-c-decls.h) against the vendored release runtimev1.0.0-20260501(the dev/AR "latest" runtime no longer definesengram_prune_telemetry— same pin as #97's CI fix), manifest entry temporarily pointed at the driver.Before (unpatched, this branch's parent
b784750)After (this branch)
Regression gate
scripts/verify-soul-contract.sh(from main) against the patched elb-built soul on a throwaway port: GATE PASS — PRESENCE 27/27 answered, IMMUTABILITY pass (all mutation routes tombstone/supersede, no hard-delete).Note on dist/
dist/soul.cregen is intentionally not included — the combined-unit assembly step is Will's, and the committed dist already lags the.elsources by 8 modules.🤖 Generated with Claude Code
Review: honest write_file/edit_file receipts (BUG-29)
Verdict: looks good / approve. The change does exactly what the title claims and does not break the tool contract.
Correctness
fs_writereturningInt(1 = all bytes written, 0 = fail) is the established contract --safety.el:477uses the identicallet write_ok: Int = fs_write(...); if write_ok == 0pattern cited in the PR body. Both new checks match it.{"error":"..."}returned throughjson_safe(...)is consistent with the handler's pre-existing convention ({"error":"file not found"}already went throughjson_safethe same way at chat.el:1795). The success path still returns{"ok":true}-- contract preserved.str_contains/str_replaceare established runtime builtins (used across awareness.el, elp-input.el). The empty-old_textguard + presence check + write-result check are all correct.Safety / consent
write_file/edit_fileare thereversiblerisk tier inclassify_tool_risk(chat.el:1672) -- they auto-run inside a chosen workspace root and rely on the run receipt as the client's undo path. That makes an honest receipt safety-critical, not cosmetic: before this fix a failed reversible write still landed{"ok":true}on the very receipt the undo path and the user's mental model depend on. #100 hardens the reversible tier. It does not alter the consent model (unscoped writes still escalate), which is the right scope. Good.One optional follow-up (pre-existing, not introduced here)
str_replace(content, old_text, new_text)replaces all occurrences, while the newstr_contains(content, old_text)guard only confirms at least one. A non-uniqueold_textwill silently edit every match and still reportok:true. That is a different class of false receipt than BUG-29 targets, so not a blocker -- but since this PR now owns edit_file's correctness contract, consider a follow-up that requires a unique match (or returns a replacement count) so "the edit landed" cannot mean "landed in N places I did not intend."Base-branch flag
This PR targets
hotfix/elc-source-typos, notmain.mainis the authoritative branch now, and there is already areconcile/hotfix-to-main-launchline in flight. As-is, this honest-receipt fix lands on a stale hotfix branch and risks being stranded there rather than reachingmain. @tim.lingo / @will: please confirm the intended landing path -- either retarget tomain, or guarantee this flows through the reconcile branch. Flagging, not blocking.Root cause: dispatch_tool's write_file returned {"ok":true} without checking fs_write's result, and edit_file returned ok:true even when old_text was absent (str_replace silently no-ops) and its fs_write was also unchecked. Any failed or no-op write fed the model a false success receipt, which it then repeated to the user as fact. The change (Receipt Contract rule 1 — a tool result must reflect what actually happened): - write_file: check fs_write's return (1 = all bytes written, 0 = fail); on failure return {"error":"write failed"} in the handler's existing error-JSON shape. - edit_file: reject empty old_text, verify old_text is actually present (str_contains) before replacing, and check the fs_write result the same way. - Verification is by operation result, NOT an fs_read read-back: fs_read arms the runtime's one-shot binary send length, the exact mechanism that truncated the safety-contact response (#96). Same honest-write pattern as that fix. E2E evidence (sandboxed elb build, dispatch_tool driven directly): - unpatched: write_file into a chmod-000 dir -> {"ok":true} (lie); edit_file with absent old_text -> {"ok":true} (lie, file untouched) - patched: same calls -> {"error":"write failed"} / {"error":"old_text not found in file"}; happy paths still ok:true - scripts/verify-soul-contract.sh on the patched soul: GATE PASS (27/27 presence + immutability) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>