You've seen this loop. Every person who runs a coding agent has seen this loop.

text
⎿ Error: String to replace not found in file.
⎿ Error: File has been modified since read, either by the user
   or by a linter. Read it again before attempting to write it.

The model reads the file. Edits it. A formatter fires on save. The next edit is rejected — the file "changed since read." The model re-reads all 800 lines. Tries the edit again. The old_string has a tab where the file has four spaces. Rejected again. Re-read. Retry. Each cycle burns a full file's worth of input tokens and produces zero lines of progress.

This isn't a model problem. The model knew exactly what change it wanted to make on the first attempt. It's a tool problem — and it comes from a specific design philosophy: when anything is uncertain, hard-fail and make the model start over.

Octofs is our answer to that philosophy. It's a filesystem MCP server written in Rust that takes the opposite bet on almost every design decision: edits never require a re-read, whitespace mismatches get fixed instead of rejected, failed matches return guidance instead of a bare error, and lines are addressed by content hashes instead of the line numbers that models are famously bad at. This post is the full story of why — with the failure data, the research, and the exact mechanics.

The Highest-Frequency Tool Call Gets the Least Design Attention

In any coding agent session, file reads and edits dominate the tool-call distribution. Not search, not shell, not MCP integrations — read and edit. Which means the failure behavior of the edit tool compounds harder than anything else in the harness: a 10% edit failure rate isn't a 10% problem, it's a tax on every multi-step task, because each failure triggers a recovery detour that consumes context, time, and the model's attention.

The SWE-agent team at Princeton put numbers on this in their NeurIPS 2024 paper on agent-computer interfaces. Two findings stand out:

  1. Interface design alone moves solve rates. Their editor with lint feedback resolved 18.0% of SWE-bench Lite. Removing the feedback dropped it to 15.0%. Removing the edit interface entirely (shell-only editing) dropped it to 10.3%. Same model, same tasks — the editing interface was worth nearly half the total performance.
  2. Failed edits poison the trajectory. In their runs, 51.7% of all task trajectories contained at least one failed edit. An edit attempt had a 90.5% chance of eventually succeeding — unless it had already failed once, after which the success probability collapsed to 57.2%.

Read that second one again. A single failed edit cuts the chance of recovery by a third. The model doesn't just lose a turn; it enters a degraded state where the conversation is polluted with error messages, duplicate file contents, and half-applied intentions. Every subsequent decision is made against that noisier context.

So the design question for an edit tool isn't "how do we behave when the edit is perfect?" Everyone handles the happy path. The real question is: when the edit is imperfect, what does the model need to succeed on the very next call?

Hard-fail designs answer: nothing. Try again from scratch.

We think every error message is a prompt, and most edit tools write terrible prompts.

The Hard-Fail School

Claude Code's built-in tools are the clearest expression of the hard-fail school, so let's use them as the reference point. Per the official tools documentation, the Edit tool enforces:

  • Read-before-edit. The model must have read the file in the current conversation, "and the file must not have changed on disk since that read. This check runs first, before any string matching."
  • Exact string match. Edit "performs exact string replacement... It does not use regex or fuzzy matching." One wrong space, one tab-vs-spaces mismatch, one CRLF — rejected.
  • Uniqueness. old_string "must appear exactly once," or the edit is rejected and the model must supply more context.
  • Line-numbered reads. Read returns cat -n style output — a line-number prefix on every line the model must remember to strip when constructing edits.

The same philosophy is codified in Anthropic's text editor tool API docs, which instruct implementers that old_str "must match exactly, including whitespace and indentation" and to return errors like "Error: No match found for replacement. Please check your text and try again."

Each rule is individually defensible. Staleness checks prevent clobbering concurrent changes. Exact matching prevents editing the wrong location. Uniqueness prevents ambiguity. We're not saying these concerns are fake; we handle every one of them, just differently.

But look at what the composite behaves like in the field, straight from the Claude Code issue tracker:

  • Issue #3513: the "File has been modified since read" error "persists even immediately after re-reading the file," creating "a read-edit-fail loop preventing file updates."
  • Issue #10498: merely opening a file in your IDE bumps the mtime and triggers the stale-read rejection. The reporter's accounting: "Every failed write requires re-reading the file and potentially regenerating code," failed attempts "consume limited conversation context," and each failure adds "5-30+ seconds of retry delay" — worst with large blocks of 400–1,000+ lines.
  • Issue #968: a Go developer hit a "100% failure rate" on edits because gofmt reformatted files between read and edit. Closed as "not planned."
  • Issue #36654: the line-number prefix on Read output adds ~8 characters of visual width, and the model calibrated its replacement text to the width it saw — producing 739 incorrectly wrapped lines in one file, across 12 PRs and 50+ edit operations.
  • Issue #25775: one user measured roughly 15–20% of edit operations failing with "String to replace not found" on the first attempt in their codebase, with tab-indented files "essentially uneditable." Their summary nails it: "This is a mechanical challenge, not an intellectual one — and it's where most edit failures originate."

That last line is the whole argument. The model isn't failing to understand the code. It's failing to reproduce bytes — invisible whitespace, formatter churn, line-ending conventions — and the tool treats a byte-reproduction failure exactly as harshly as a comprehension failure. The punishment for both is the same: full stop, re-read, try again.

What the Rest of the Field Figured Out

The evidence that strict byte-matching is the wrong contract has been accumulating for years, from independent teams who arrived at the same conclusions separately.

Aider has run the most public benchmarks on edit formats since 2023. Two findings matter here. First, on line numbers: models are "terrible at working with source code line numbers" — aider strips them from diff hunks entirely and applies hunks as search/replace. Second, on flexible application: aider's editor doesn't demand byte-exact search blocks. It normalizes whitespace, tolerates uniform indentation drift, forgives spurious blank lines, and honors ... elision markers. Removing these "flexible patching strategies," Paul Gauthier reports, causes a "9X increase in editing errors" in their benchmarks. Those numbers come from earlier model generations; the mechanics they expose haven't changed.

OpenAI's apply_patch (the format behind Codex) is context-anchored by design. From OpenAI's prompting cookbook, verbatim: "we do not use line numbers in this diff format, as the context is enough to uniquely identify code." And the apply_patch guide instructs harness builders that on failure they should "include a helpful output string so the model can recover" — errors as advisory feedback, recovery as the model's job. Soft guidance, by contract.

Cursor went further and concluded diffs themselves are the problem. Their instant apply post lists three reasons they have the model rewrite whole files and let a separate fine-tuned "apply model" merge the changes: diffs force the model to think in fewer tokens, diff syntax is out-of-distribution relative to pretraining, and "models are notoriously bad at counting line numbers." Morph built a business on the same idea — a 7B merge model applying lazy edits at a claimed 10,500 tokens/second, with their (vendor-reported) numbers putting search-and-replace at "84–96% (brittle)" reliability and "2-3.5x more retry turns on failures."

And earlier this year, a benchmark made the harness-vs-model distinction impossible to ignore. "The Harness Problem" tested 16 models on identical bug-fix tasks while changing only the edit interface. With the OpenAI-style patch format, some models failed catastrophically. With a hash-addressed line format — each line tagged with a short content hash the model references instead of reproducing text — Grok Code Fast went from 6.7% to 68.3% task success. Output tokens dropped 61% for some models. The author's thesis: "the model isn't flaky at understanding the task. It's flaky at expressing itself."

Three independent camps — flexible matching (aider), context anchoring (OpenAI), and apply models (Cursor/Morph) — all converging on the same underlying insight: don't make task success contingent on the model perfectly reproducing file bytes.

Octofs is our synthesis of that insight, built as a deterministic Rust MCP server: no second model in the loop, no special diff dialect to learn, sub-millisecond matching — and a recovery path designed into every failure.

Design Principle 1: No Staleness Police

Octofs has no read-before-edit gate. No mtime tracking. No "file has been modified since read" error — the string doesn't exist in the codebase.

Instead, every edit operates on the file as it exists on disk right now. When str_replace or batch_edit runs, octofs reads the current content fresh, applies the operation, and writes atomically (temp file + rename, permissions preserved). Concurrent writes within octofs are serialized by per-file async locks keyed on canonicalized paths, so two parallel edits can't interleave.

"But doesn't that risk editing a file the model has a stale picture of?" Yes — and that's the right trade, for two reasons.

First, the staleness check doesn't actually protect what people think it protects. The model's old_string is itself a consistency check: if a formatter rewrote the region the model is targeting, the match won't land, and octofs will show the model what's actually there (more on that below). If the formatter touched some unrelated region, the edit lands fine — which is exactly the case where Claude Code's mtime check generates a pure false positive and forces a pointless 800-line re-read. The mtime check rejects edits based on whether anything anywhere in the file changed; content matching rejects edits based on whether the thing being edited changed. The second is the check you actually want, and you get it for free.

Second — and this is the part that makes the whole design click — the model never needs a full re-read to stay synchronized, because of principle 2.

Design Principle 2: The Diff Is the Result

Every successful octofs edit returns an annotated diff as the tool result — removed lines, added lines, and two lines of surrounding context, addressed exactly like view output:

text
...
12:     let config = load_config()?;
13:     let pool = Pool::new(&config);
-14:    let timeout = 30;
+14:    let timeout = config.timeout_secs.unwrap_or(30);
15:     let server = Server::bind(&addr)
16:         .timeout(timeout)
...

This is the mechanism that replaces the staleness police. The model doesn't need to re-read the file to verify the edit landed or to keep its mental model current — the result is the verification, scoped to exactly the region that changed. After a batch_edit with five operations, the model gets five diff blocks showing each landing site with fresh, valid line addresses for the new content.

Compare the token economics directly. In a hard-fail harness, the verify-and-continue cycle after an edit to an 800-line file is either "trust it blindly" or "re-read 800 lines" (~8,000+ tokens). In octofs it's the ~10-line diff you already received for free in the tool result. Over a 40-edit refactoring session, that difference alone is tens of thousands of input tokens — before counting a single failure loop.

Design Principle 3: Three Chances to Land, Then Advice — Never a Bare Error

The core of octofs is a progressive matching cascade in str_replace:

text
old_text arrives
      │
      ▼
┌─ Stage 1: exact match ────────────────────────────────┐
│  found once  → apply, return diff                     │
│  found 2+    → stop, list every location              │
│  found zero  → fall through                           │
└───────────────────────────────────────────────────────┘
      │
      ▼
┌─ Stage 2: whitespace-normalized fuzzy match ──────────┐
│  collapse each line to single-spaced tokens,          │
│  match line-by-line against the normalized file       │
│  found once  → auto-adjust indentation, apply,        │
│                return diff + advisory hint            │
└───────────────────────────────────────────────────────┘
      │
      ▼
┌─ Stage 3: closest-match diagnostics ──────────────────┐
│  scan sliding windows, rank by per-line LCS           │
│  similarity, return top 3 candidates ≥40%             │
│  with locations, similarity %, and a diagnosis        │
└───────────────────────────────────────────────────────┘

Stage 2 is where the tabs-vs-spaces problem dies. Each line of old_text and each line of the file is collapsed to its whitespace-normalized form — tokens joined by single spaces. Tabs, double spaces, indentation depth: all irrelevant to matching. If exactly one normalized match exists, octofs applies the replacement, then does the careful part: it re-indents the new text to match what's actually in the file, not what the model typed. The indentation detected at the real match site is grafted onto every line of the replacement. The model sent four spaces, the file uses tabs? The file keeps its tabs.

The model is told what happened, via a hint attached to the result:

text
Replaced via fuzzy match (whitespace-normalized). Indentation
was auto-adjusted to match the file.

Note what this hint is not: an error, a failure, a turn lost. The edit succeeded. The hint exists so the model can calibrate — next time, copy the actual file content instead of retyping from memory.

Stage 3 is the part we're proudest of. When nothing matches even after normalization, the model genuinely has a wrong picture of the file — maybe stale content, maybe a hallucinated identifier. A hard-fail tool says "String to replace not found in file" and leaves the model to guess what went wrong. Octofs runs a sliding-window similarity scan (per-line longest-common-subsequence ratio) and returns the closest candidates with a diagnosis of why each one didn't match:

text
No exact match found. Make sure you pass raw content (no escaped \t, \n).
Closest matches:

  1. Lines 142-145 (87% similar, whitespace/indentation mismatch only)
     142: fn handle_request(req: Request) -> Response {
     143:     let auth = req.headers.get("Authorization");
     144:     match validate(auth) {
     ... (1 more lines)

  2. Lines 203-206 (52% similar, content differs)
     203: fn handle_webhook(req: Request) -> Response {
     ...

Tip: use `batch_edit` with the line range shown above, or fix the
`old_text` content.

Every piece of that message is doing recovery work. The locations tell the model where to look. The similarity percentage tells it how wrong its picture is. The diagnosis distinguishes "your content is right, your whitespace is off" from "you're thinking of different code." The preview shows the actual current bytes. And the tip names the concrete next action. One failed call, and the model has everything it needs to succeed on the next one — usually without even issuing another read.

That's the difference in philosophy, compressed into a single error message. Recall the SWE-agent statistic: after one failed edit, recovery probability drops from 90.5% to 57.2%. That drop is exactly what a well-designed failure message exists to prevent.

Design Principle 4: Soft on Expression, Hard on Intent

Octofs is not "anything goes." It hard-stops in three situations — and the line between soft and hard is principled:

Ambiguity. If old_text matches multiple locations, applying any one of them is gambling with the user's code. Octofs refuses — but look at what the refusal contains:

text
Found 3 matches for replacement text at:
  1. line 47
  2. line 112
  3. line 289
Add more surrounding context to make a unique match, or use
`batch_edit` with the specific line range.

Claude Code's equivalent tells the model to "provide more context" and nothing else — the model must re-read the file to find out where the matches even are. Octofs hands over the locations, so disambiguating is a single follow-up call.

Conflicting batch operations. batch_edit applies multiple operations to one file atomically — all validated before anything is written, all-or-nothing. Overlapping replace ranges or two inserts at the same anchor are rejected upfront, naming the exact operations in conflict. A half-applied batch is corruption; we never write one.

Duplicate-line insertion. This one deserves its own section.

The rule that falls out of these cases: never punish the model for failing to reproduce bytes; always stop it when its intent is ambiguous or destructive. Whitespace, tabs, indentation drift, stale mtimes — those are expression problems, and the tool absorbs them. Multiple matches, overlapping ranges, duplicated code — those are intent problems, and the tool stops, explains, and hands the model a specific path forward.

Design Principle 5: The Duplicate-Line Guard

There's a failure mode every heavy agent user has encountered but few tools acknowledge: the model includes surrounding context lines inside its replacement content, and the edit silently duplicates them. You asked it to replace lines 10–15; it helpfully included line 9 at the top of its new content; now line 9 appears twice and the file may not even fail to compile — it just quietly has two of something.

This is insidious precisely because it succeeds. No error, no diff rejection — just corrupted code that surfaces three steps later as a confusing test failure.

Octofs checks for it structurally on every batch_edit replace and insert: if the first line of the replacement content matches the line immediately before the target range, or the last line matches the line immediately after it, the operation is rejected:

text
Duplicate line detected in operation 1: content's first line
matches line 9 (just before the replacement range [10,15]).
9: "fn double(x: i32) {". Do NOT include surrounding unchanged
lines — only provide the lines that replace [10,15].

One subtlety that took real-world tuning: pure structural punctuation — a lone }, ], or ), optionally followed by a comma or semicolon — is exempt. Those legitimately repeat at range boundaries in any brace-delimited language, and flagging them would turn the guard into a nuisance. The guard targets the failure mode, not the pattern.

Multi-line inserts get the block-level version of the same check: if the first N lines of inserted content match the N lines already sitting at the insertion point, that's a paste-twice in progress, and it's stopped before it lands.

Design Principle 6: Hashes Instead of Line Numbers

Run octofs with --line-mode hash and view output changes from this:

text
1: fn main() {
2:     println!("Hello");
3: }

to this:

text
a3bd: fn main() {
c7f2:     println!("Hello");
e9f1: }

Each line gets a 4-character hex tag — an FNV-1a hash of position:content, folded to 16 bits. Including the position in the key means duplicate lines (every } in the file, every blank line) still get unique tags, with zero collision-resolution machinery. Line references in batch_edit, view ranges, and extract_lines all accept hashes in place of numbers: "line_range": ["a3bd", "c7f2"].

Why bother? Because line numbers are arithmetic, and arithmetic is exactly what language models do worst in the middle of a long context. Aider: models are "terrible at working with source code line numbers." Cursor: "models are notoriously bad at counting line numbers." SWE-agent, on why shell-based editing fails: "LMs must track many states (e.g. line #s, pre/post-edit)." A line number is a value the model must compute — count from the top, adjust for every preceding edit, don't be off by one. A hash is a value the model copies — it appeared in the view output or the last edit's diff, and pasting it back is a pure transcription task.

Hashes also carry a property numbers can't: they verify the model's picture of the file at exactly the right granularity. If the model references hash a3bd and that line has since changed — content or position — the hash no longer resolves, and the error names the precise stale reference. Contrast with the mtime check, which answers only "did anything in the file change?" and responds by invalidating the model's knowledge of all 800 lines. A hash miss invalidates one line, and the fix is one targeted view of that region.

And because every edit's result diff is printed with fresh hashes for the changed region, the model's addressing stays current automatically — edit, receive new hashes in the diff, use them in the next edit. No re-read in the loop, ever.

This isn't a fringe idea. The hash-addressed format in the Harness Problem benchmark — same concept, independently developed — took models from single-digit to 68%+ task success and cut output tokens by 61%, and a proposal for content-hash line addressing sits in Claude Code's own issue tracker. We just shipped it.

(Number mode remains the default — hashes cost a few extra tokens per line, and for short sessions on small files plain numbers are fine. It's one flag.)

The Supporting Cast

A few smaller decisions follow the same philosophy:

  • Range replace skips the old text entirely. str_replace makes the model pay twice — transmit the old block, then the new one. batch_edit replace takes a line range (or hash range) plus new content: the model reads once, then says "replace lines 140–170 with this." For a 30-line block, that's roughly half the edit payload, and there's no match to miss — the range is the address. The hint system actively steers the model here: "this str_replace matched 14 lines; prefer batch_edit with a line range — it's faster and avoids content-search ambiguity."
  • Batch edits keep original line numbers valid throughout. Within one batch_edit, all operations reference the file as the model last saw it. Octofs applies replaces in reverse order and remaps insert anchors internally, so the model never does post-edit offset arithmetic — the tool does.
  • Hints are a side channel, not failures. Advisory messages like the one above accumulate during execution, get deduplicated, and ride along with successful results. The model gets steered without ever losing a turn.
  • Undo is built in. Every write snapshots the prior content (10 levels per file, in-memory). undo_edit is one call. Agents make wrong edits; recovery should not require git checkout.
  • Atomic writes preserve permissions. Temp-file-plus-rename, with the original file's mode bits re-applied. Your executable scripts stay executable.
  • Reads are gitignore-aware and elision-smart. Directory listings respect .gitignore; large view ranges elide the middle ([...N lines more]) instead of flooding the context window.

Where Each Approach Lands

Match contractOn whitespace mismatchOn no matchOn stale fileLine addressingExtra model in loop
Claude Code / Anthropic text editorByte-exactHard failHard fail, no guidanceHard fail, full re-readLine-numbered readsNo
OpenAI apply_patch (V4A)Context-anchoredDepends on harnessTool error, model retriesContext mismatch errorNone (context anchors)No
AiderFlexible (whitespace-tolerant, elision-aware)Normalized & applied"Did you mean" similar-lines feedback, max 3 retriesN/A (applies to current file)None (search blocks)No
Cursor / Morph apply modelsNone (full rewrite, merged)Absorbed by apply modelApply model failureN/ANoneYes — second model
OctofsExact → normalized fuzzy → diagnosticsNormalized, indentation auto-adjusted, advisory hintTop-3 candidates with similarity % and diagnosisNo gate; diff-as-result keeps model syncedNumbers or content hashesNo

The apply-model approach (Cursor, Morph) genuinely works — but it puts a second LLM, with its own latency, cost, and failure distribution, inside every single edit. Octofs gets the forgiveness deterministically: the fuzzy cascade is string algorithms in Rust, sub-millisecond, and it either provably matched one location or it tells you why not. Same destination, no dice.

What This Adds Up To

Tally the cost of one formatter-induced failure loop in a hard-fail harness against octofs, on an 800-line file:

Hard-fail: edit rejected (stale read) → re-read 800 lines (~8,000 tokens) → edit rejected (tab mismatch) → re-read → model manually re-types the block, guessing at whitespace → maybe lands on attempt three. Cost: ~16,000+ wasted input tokens, three turns, and — per SWE-agent's data — a trajectory that now succeeds a third less often.

Octofs: Stage 2 normalizes the whitespace, adjusts the indentation, applies the edit, returns a 10-line diff with a one-line hint. Cost: zero extra turns. The "failure" never existed.

That's the design goal in one sentence: the model should spend its tokens deciding what to change, and approximately zero deciding how to get the tool to accept it. Tokens spent re-reading unchanged files, re-typing whitespace, and recovering from mechanical rejections are pure waste — they don't make the code better, they make the context worse. Multiply across the highest-frequency tool call in every session, and the edit tool's failure design quietly becomes one of the biggest levers on both your token bill and your task success rate.

The models will keep getting better at understanding code. They will not get better at reproducing invisible bytes — there's nothing to get better at; it's noise. Harnesses that demand byte-fidelity are betting against thermodynamics. Harnesses that absorb expression noise and stop only on genuine ambiguity are betting on what models are actually good at.

We built octofs to make the second bet, deterministically, in Rust. It's the filesystem layer for Octomind, our coding agent — but it's a standard MCP server, so nothing ties it there. Plug it into Claude Code, Codex, Cursor, Windsurf, or anything else that speaks MCP: point the config at octofs mcp --path /your/project, flip on --line-mode hash if your sessions run long, and watch the read-edit-fail loop disappear from your transcripts. Play with it — and if you find an edit it handles badly, that's exactly the kind of issue we want.


Octofs on GitHub · Octomind on GitHub · Documentation · Related reading: Guardrails for AI Agents · MCP Tools Deep Dive · What Is Context Rot