Skip to content

feat(edit): enforce read-before-edit at the dispatch gate#1563

Merged
esengine merged 1 commit into
mainfrom
worktree-read-before-edit-gate
May 22, 2026
Merged

feat(edit): enforce read-before-edit at the dispatch gate#1563
esengine merged 1 commit into
mainfrom
worktree-read-before-edit-gate

Conversation

@esengine

Copy link
Copy Markdown
Owner

Summary

  • New ReadTracker on CacheFirstLoop records files the model has seen this session (via read_file or write_file).
  • edit_file / multi_edit now consult it via ToolCallContext and refuse unread targets up front with "<path> was not read this session — read_file first". multi_edit is all-or-nothing across the batch.
  • Tracker resets on ContextManager.fold / mechanicalTruncate (new onLogRewrite callback) — the model's byte-level view of folded history is gone, so re-reads are required before mutating again. In-memory only, so session resume also starts empty.
  • Repeat-rejection detector in tools.ts recognizes the gate, so a 2nd identical unread edit gets the sharper "do not retry identical args — call read_file on the target path first" instead of echoing the same refusal.
  • Prompt in src/code/prompt.ts upgrades the soft - read_file first so SEARCH matches byte-for-byte. bullet to an enforced rule that names the gate behavior.

Why

Soft prompt hints don't bind. A model that skips read_file and guesses its SEARCH text wastes a tool round on "search text not found", retries with another guess, and burns cache-miss tail tokens — and the user watches it flail. Failing fast at dispatch with a clear "call read_file first" message turns a multi-round failure into a single corrective hop, and forces evidence into the prefix before any mutation.

Claude Code enforces the same gate on its Edit tool; aligning the contract on our side closes the most visible "agent is dumb" complaint about edits.

Test plan

  • tests/read-before-edit-gate.test.ts — 11 new cases: refusal, post-read success, write_file-counts-as-read, partial-read (range/head) accepted, multi_edit all-or-nothing, repeat-rejection sharpening, reset semantics, backwards compat when no tracker is injected, Windows path normalization.
  • Full suite: 3560 passed | 16 skipped — no regressions in tests/preflight.test.ts, tests/loop.test.ts, tests/filesystem-tools.test.ts, tests/edit-blocks.test.ts.
  • tsc --noEmit clean.
  • biome check clean on touched files.
  • tests/comment-policy.test.ts clean (gate enforced on every PR).

Soft prompt hint ("read_file first so SEARCH matches byte-for-byte")
was easy for the model to skip — the failure mode is a tool round
spent on "search text not found", a guess at the bytes, and a retry.
Each round is a cache-miss tail and an obviously-not-grounded edit
that erodes trust.

New ReadTracker lives on CacheFirstLoop and threads through
ToolCallContext. read_file marks the absolute path on any successful
load (full, range, head, tail, outline); write_file marks too — the
model knows what it just wrote. edit_file / multi_edit consult the
tracker before applying and refuse unread targets with a message
that names the path and tells the model to call read_file first.
multi_edit refuses the whole batch if any target is unread, so a
partial-knowledge cross-file refactor can't half-apply.

ContextManager fires a new onLogRewrite callback from fold and
mechanicalTruncate; the loop wires it to ReadTracker.reset() because
once the byte-level history is folded away the model's "I saw it"
claim no longer holds. The tracker is in-memory only, so session
resume also starts empty — the model must re-read before editing
after a restart.

tools.ts's repeat-rejection detector recognizes the "read_file first"
gate so a 2nd identical unread edit gets the sharper "do not retry
identical args — call read_file on the target path first" hint
instead of the same refusal echoed back.

Path normalization lower-cases on win32 so case variants of the same
file resolve to one tracker entry.

11 new tests in tests/read-before-edit-gate.test.ts cover: refusal,
post-read success, write_file-counts-as-read, partial-read accepted,
multi_edit all-or-nothing, repeat-rejection sharpening, reset
semantics, and backwards compat when no tracker is injected.
@esengine esengine merged commit 7ae9353 into main May 22, 2026
4 checks passed
@esengine esengine deleted the worktree-read-before-edit-gate branch May 22, 2026 15:36
esengine added a commit that referenced this pull request May 22, 2026
read-before-edit gate (#1563) and cache-aligned fold summary (#1565)
landed after the release commit was written. Document them in the
0.49.0 entry before tagging so the published CHANGELOG matches what
ships.

Co-authored-by: reasonix <reasonix@deepseek.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant