Skip to content

agent: edit takes a path per entry; each file applies on its own - #249

Merged
jaredLunde merged 3 commits into
mainfrom
jared/edit-per-file
Oct 12, 2026
Merged

jaredLunde merged 3 commits into
mainfrom
jared/edit-per-file

Conversation

@jaredLunde

Copy link
Copy Markdown
Contributor

Follows closed #248. This keeps #248's per-entry path and diagnostic errors, but each file of a multi-file call now applies on its own. Draft: no confirmation A/B has run yet, so whether to run one is the owner's call.

Why

#248 was all-or-nothing across files, and its Luna A/B showed no gain: edit errors went from 27.6% to 25.7% per call. Wrong-file errors went away, but the all-or-nothing rule became the top error. Luna packs a call: 3.9 entries across 1.4 files on average. So one bad entry sank whole batches.

Change

  • Each file applies on its own. A file applies all of its entries or none of them, exactly like a single-file call today, and independently of the call's other files.
  • Errors. The call is an error only when no file was written.
  • Partial success. When some files land and some fail, the result is a success that lists both, so the model resends only the failed files:
    edited 2 of 3 files; resend only the FAILED files' edits (the edited files are done):
    edited a.py (1 replacement)
    edited c.py (1 replacement)
    FAILED b.py (not edited):
    `old_string` not found in edits[1]: "beta = 999" in /work/b.py
    <the usual diagnosis from #248>
    
  • When every file fails. The error has the same shape and starts with "no file was edited".
  • Single-file calls read and behave exactly as before.
  • Write refusals. A file that changed on disk between plan and write is reported, and the files after it are still written. Before, the first refusal stopped the rest.
  • File names in results. A multi-file result names each file as the model spelled it, so a failed file can be resent under the same path.
  • "That text is in X" hint. A file that is about to be written is searched in its new text, so the hint never points at text the same call just replaced.
  • Tool description. It now says each file applies on its own and that the result lists which files were edited and which failed.

What the gates and tracking count

  • Decided before anything applies, so they cover the whole call (unchanged from agent: edit takes a path per entry and explains a failed match #248):
    • Dispatch write lock.
    • --deny-path: a denied file blocks every file of the call.
    • Approval: one approval covers the call's whole sorted set of files.
  • Count only the files that landed:
    • TouchedFiles.
    • Compaction's modified files (which masking also reads). agent_core::tool::edit_failed_paths parses the FAILED <path> (not edited): lines and subtracts them from the call's written_paths. The tool writes those lines with edit_failed_line, so writer and reader share one definition. A single-file failure has no such line, so it counts its path as before.
  • Bash writes. extract_bash_writes is unaffected.
  • Sandbox FsBackend. A remote backend writes the file that plans and leaves the failing one untouched. There is a test for this.
  • Small fix. A blank top-level path on an edit (Luna sends "" beside entries that each name a file) no longer counts as a written path.

Tests

  • Partial success: one file applies and another fails; checks the exact result shape, edit_failed_paths, that TouchedFiles holds only the files that landed, and the disk contents.
  • Every file fails: an error listing each file under its FAILED line, with nothing written.
  • Missing file: fails alone; when all files are missing, it's an execution error.
  • Refused write: a disk-changed refusal fails alone while the rest are written.
  • Remote backend: a remote FsBackend partial.
  • No-op file: left alone beside an edited file and a failing file.
  • Hint: never names a file whose text the same call replaced.
  • Compaction: modified files count only the landed files, for a partial success, an all-failed call, a single-file failure (counted as before) and a blank top-level path.
  • Subagent: an approved partial call asks for approval once and lands only a.txt. The deny test now notes the gate blocks the whole call.
  • cargo test -p beyond-ai-agent -p beyond-ai-agent-core: 3,759 passed, 0 failed (155 targets); clippy -D warnings and fmt clean.

Offline replay (free; no paid runs)

These come from the per-call data of #248's candidate arm (cand-47db6dea): 25 Luna runs, 1,204 edit calls, 4,728 entries, 347 multi-file calls. Whole-run outcomes were ignored because rate limits confounded them.

Ground truth is each rejection's own list of files that were "valid but not written". The shadow file states agree with it on all 320 files they could check.

Caveat: this is first-order. It does not model how the model would have acted after a partial success.

all-or-nothing (#248) per file (this PR)
all-or-nothing rejections that would apply ≥1 file — 112 / 131 (85.5%)
entries of those rejections that would land 0 288 / 768 (37.5%)
entries lost to failed calls/files 32.5% 26.4% (−6.1 pts)
pooled edit error rate (error = no file applied) 25.2% 15.9%
mean per-run edit error rate (n = 25) 23.8% 15.9% (Δ −8.0 pts, CI −11.2 to −4.9)
mean per-run entry loss 29.6% 24.6% (Δ −5.0 pts, CI −7.0 to −3.2)

The replay script and its output are in /home/jared/longctx-eval/edit-ab2/ (replay_perfile.py, replay_perfile.out).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk

jaredLunde and others added 3 commits October 12, 2026 13:11
Luna still failed 27.3% of its edit calls on main. The two largest causes
were the call's shape: an `edits[]` batch whose entries belonged to two
files sent under one `path` (28.7% of errors), and an ambiguous
`old_string` (25.9%).

- An `edits[]` entry may name its own `path`; the top-level `path` stays the
  default, so a single-file call is unchanged. Every file is planned before
  any is written: one bad entry anywhere writes nothing, and the error lists
  each failing file. The dispatch lock covers every file
  (`Tool::write_targets`, `WriteLockRegistry::lock_all` in sorted order);
  `--deny-path`, approval scoping and compaction's modified files read every
  path through one `tool::written_paths`.
- A failed match never applies anything new; it explains itself. Not found
  names another file of the call, or one `read`/`write`/`edit` recently
  touched, that holds the exact text; else "already applied at line N";
  else the closest lines with numbers. Not unique lists each match's line
  and enclosing definition. Bounded and error-path only.

Replayed through the real tool against the 174 remaining Luna@main errors:
the original call still fails in all 174; giving each wrong-file entry its
file rescues 32 (18.4%) with no apply the model did not name, and the new
error names the right file for 47 of the 58 wrong-file calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
Luna pads an `edits[]` batch with `old == new` entries. When every entry
for one file was such padding, the per-file "made no changes" check failed
the whole atomic call and the real edits to the other files were lost — 4
of the candidate's errors in the first A/B attempt. A call that changes no
file still errors; a file of a multi-file call that changes nothing is now
left alone and named in the result.

The replay through the real tool now rescues 35 of the 174 remaining
Luna@main errors (20.1%), still with no apply the model did not name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
The all-or-nothing rule across files sank whole batches: Luna packs 3.9
entries across 1.4 files per call, and in the A/B's candidate arm 131 of
303 failed calls were all-or-nothing rejections, 112 of them (85.5%) with
at least one file the tool itself reported valid.

- A file applies all of its entries or none, independent of the call's
  other files. The call errors only when no file was written; otherwise
  the result lists each edited file and each FAILED one, with the usual
  diagnosis, so the model resends only the failed files. A disk-changed
  refusal fails that file alone and the rest are still written.
- A multi-file result names files as the model spelled them. Single-file
  calls are unchanged.
- The "text is in X" hint searches a file being written in its new text.
- Deny-path, approval and the write lock still decide the whole call
  before anything applies. TouchedFiles and compaction's modified files
  count only the files that landed (`tool::edit_failed_paths`). A blank
  top-level `path` on an edit no longer counts as a written path.

Replaying the candidate arm's calls per file cuts the entries lost to
failed calls from 32.5% to 26.4% and the pooled edit error rate from
25.2% to 15.9%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
@jaredLunde
jaredLunde marked this pull request as ready for review October 12, 2026 23:29
@jaredLunde
jaredLunde enabled auto-merge October 12, 2026 23:29
@jaredLunde
jaredLunde added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit eca9095 Oct 12, 2026
13 checks passed
@jaredLunde
jaredLunde deleted the jared/edit-per-file branch October 12, 2026 23:47
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