Skip to content

File Edit

The file edit tool allows the model to make targeted changes to existing files. Unlike writing a complete file from scratch, editing applies a diff or patch — specifying what to remove and what to add while leaving the rest of the file untouched. This is more token-efficient for small changes and more predictable for multi-file operations where only specific regions change.

The key tension is between expressiveness and precision. A text-based patch format is easy for the model to emit but fragile under fuzzy matching. A structured format (line numbers plus old/new content) is precise but requires the model to have read the exact current file content first. Real implementations use a layered approach: a compact text-based format with a fuzzy matching fallback.

This page covers the tool API surface — parameter schema, execution phases, permission gating, and output format. The underlying patch format grammars and fuzzy matching strategies are documented in detail in Edit Formats and Streaming Edits.



Aider has no file edit tool in the JSON tool-call sense. All file modifications flow through text-based format parsing inside the agent loop.

The model produces a complete response containing one or more edit blocks in the chosen format (SEARCH/REPLACE, unified diff, whole file, etc.). The coder class parses the response text after the stream completes and applies edits by invoking format-specific appliers like do_replace() in aider/coders/editblock_coder.py or apply_edits() in aider/coders/udiff_coder.py.

Because there is no tool call:

  • There is no per-edit permission gate; the entire set of edits in a response either runs or is rejected
  • There is no structured metadata (diff text, file paths) returned to the model in tool result format
  • Fuzzy matching failures produce text messages inserted into the next user turn, not structured error objects

The architecture is simpler but offers less per-operation control.


Codex exposes apply_patch defined in codex-rs/core/src/tools/spec.rs with the handler in codex-rs/core/src/tools/handlers/apply_patch.rs (commit 4ab44e2c5).

struct ApplyPatchToolArgs {
/// The full patch text in the Codex patch format.
input: String,
}

A single input field containing the complete patch text. The model emits the entire patch — potentially touching multiple files — in one tool call.

Codex uses its own text grammar (defined in codex-rs/core/src/tools/tool_apply_patch.lark):

start: ("***" "Begin" "Patch")
(file_operation)+
("***" "End" "Patch")
file_operation: add_file | update_file | delete_file
add_file: "***" "Add File:" path NEWLINE content_lines
update_file: "***" "Update File:" path NEWLINE move_line? hunks
delete_file: "***" "Delete File:" path NEWLINE
move_line: "***" "Move to:" path NEWLINE
hunks: hunk+
hunk: context_line* change_line+ context_line*
context_line: " " REST_OF_LINE NEWLINE
change_line: ("+" | "-") REST_OF_LINE NEWLINE

Example patch:

*** Begin Patch
*** Update File: src/main.rs
@@
fn main() {
- println!("Hello");
+ println!("Hello, world!");
}
*** End Patch

@@ introduces a hunk. Context lines (space-prefixed) anchor the location. Removed lines are prefixed with -, added lines with +.

apply_patch.rs:80–183 contains two code paths for applying patches:

Path 1: Direct parsing (apply_patch.rs:107–115):

match parse_patch(&args.input) {
Ok(patch) => {
let result = apply_patch::apply_patch(&patch, &working_dir)?;
return Ok(ToolOutput::text(result));
}
Err(_) => { /* fall through to shell path */ }
}

If the Lark grammar parses cleanly, the patch is applied in-process without spawning a subprocess. This is the fast path.

Path 2: Shell delegation (apply_patch.rs:117–163):

let req = ApplyPatchRequest {
action: apply.action,
file_paths, // Vec<AbsolutePathBuf> for approval tracking
changes, // protocol-serialized change list
exec_approval_requirement,
timeout_ms: None,
codex_exe, // path to the codex binary for subprocess invocation
};
orchestrator.run(&mut runtime, &req, &tool_ctx, &turn, approval_policy).await

If direct parsing fails (unusual patch syntax, escaping issues), Codex delegates to ApplyPatchRuntime, which invokes the codex-rs/apply-patch crate as a subprocess. This enables streaming output and routes through the full approval overlay.

apply_patch.rs:39–61:

fn file_paths_for_action(action: &ApplyPatchAction) -> Vec<AbsolutePathBuf> {
// Extracts all paths touched by the patch:
// - source path (for update/delete)
// - destination path (for move/rename: move_path field)
// Returns Vec<AbsolutePathBuf> used for approval tracking
}

Both the source and destination paths of renames are included so the approval system can show the full scope of the change.

The file_paths extracted above feed into the ApprovalOverlay (documented in Approval Flow). The overlay shows the user a diff summary per file and offers [Y]es / [N]o / [A]lways / [P]revious / [C]ancel options. For patch application, Always grants auto-approval for future patches to the same paths.

The tool result is a plain text string. Success:

Patch applied successfully.
Updated: src/main.rs
Added: src/lib.rs
Deleted: src/old.rs

Failure returns an error description identifying which hunk failed and at what file/line.


OpenCode exposes apply_patch in packages/opencode/src/tool/apply_patch.ts (commit 7ed44997).

parameters: z.object({
patchText: z.string()
.describe(
"The full patch text that describes all changes to be made. " +
"Must begin with '*** Begin Patch' and end with '*** End Patch'."
),
})

Same single-field design as Codex. The entire multi-file patch is one tool call.

OpenCode uses the same *** Begin Patch / *** End Patch text grammar as Codex. The format is documented in packages/opencode/src/tool/apply_patch.txt and supports:

OperationSyntax
Add file*** Add File: <path>
Update file*** Update File: <path>
Rename during update*** Move to: <new_path>
Delete file*** Delete File: <path>
Hunk anchor@@
Context line<space><content>
Addition+<content>
Deletion-<content>

OpenCode’s apply pipeline is more structured than Codex’s. apply_patch.ts executes in five sequential phases:

Phase 1: Parse (apply_patch.ts:29–90)

// Parse patchText into hunks:
// [{type: "add"|"update"|"delete", path, chunks, movePath?}]
const hunks = parsePatch(params.patchText);

Each hunk identifies the file type (add/update/delete), the target path, the list of change chunks, and an optional rename destination.

Phase 2: Compute New Contents (apply_patch.ts:91–160)

for (const hunk of hunks) {
const original = await fs.readFile(hunk.path, "utf8");
const newContent = applyChunks(original, hunk.chunks); // fuzzy match + apply
const diff = createUnifiedDiff(original, newContent, hunk.path);
results.push({ hunk, original, newContent, diff, additions, deletions });
}

applyChunks() uses a 4-pass fuzzy-seeking strategy (exact match → stripped match → Unicode-normalized match → partial match) identical to OpenCode’s general patch applier. This happens before any permission check, so the permission dialog can show an accurate diff.

Phase 3: Permission Request (apply_patch.ts:174–185)

await ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"], // auto-approve all edits once permission granted once
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff, // full unified diff across all files
files: results.map(r => ({
filePath: r.hunk.path,
relativePath: r.hunk.relativePath,
type: r.hunk.type,
diff: r.diff,
before: r.original,
after: r.newContent,
additions: r.additions,
deletions: r.deletions,
movePath: r.hunk.movePath,
})),
},
});

The metadata is rendered in the TUI’s permission dialog: per-file before/after diffs, addition/deletion counts, move destinations. The user sees the full impact before approving.

Phase 4: Apply Changes (apply_patch.ts:187–227)

for (const { hunk, newContent } of results) {
if (hunk.type === "add" || hunk.type === "update") {
await fs.mkdir(path.dirname(hunk.path), { recursive: true });
await fs.writeFile(hunk.path, newContent);
if (hunk.movePath) {
await fs.unlink(hunk.path); // delete old path on rename
}
} else if (hunk.type === "delete") {
await fs.unlink(hunk.path);
}
// Publish FileWatcher.Updated event for each changed path
FileWatcher.publish({ type: "updated", path: hunk.path });
}

Writes are atomic at the file level (single writeFile call). There is no rollback on partial failure — if the third file in a five-file patch fails, the first two are already written. The model receives an error message identifying the failure point and must decide how to recover.

Phase 5: LSP Diagnostics (apply_patch.ts:234–269)

const MAX_DIAGNOSTICS_PER_FILE = 20;
for (const path of modifiedPaths) {
await LSP.touchFile(path, true); // signal LSP server to re-check file
}
const diagnostics = await collectDiagnostics(modifiedPaths, {
severity: DiagnosticSeverity.Error, // errors only, not warnings
timeout: 3000, // 3s debounce wait
maxPerFile: MAX_DIAGNOSTICS_PER_FILE,
});
if (diagnostics.length > 0) {
output += formatDiagnosticsXml(diagnostics);
}

After writing, OpenCode pings each LSP server to re-check the modified files. This is the same mechanism used in LSP Diagnostics. The tool result includes the error output, so the model can self-correct without a separate read-then-lint cycle.

Success. Updated the following files:
[M] src/main.rs
[A] src/new_feature.rs
[D] src/old_feature.rs
[M→M] src/renamed.rs → src/new_name.rs
<diagnostics>
<file path="src/main.rs">
<error line="42" col="5">expected `;`</error>
</file>
</diagnostics>

[A] = added, [M] = modified, [D] = deleted. Rename is shown as [M→M]. Diagnostics block is omitted if there are no errors.


Both Codex and OpenCode apply changes file by file with no transactional guarantee. If a five-file patch fails on file three, files one and two are already written. The model must re-read the affected files to understand the current state and decide whether to continue or revert. OpenOxide should consider writing all files to a staging area first and committing atomically, or at minimum creating a ghost snapshot before applying (as documented in Ghost Commits).

The 4-pass fuzzy seek in OpenCode can match the wrong location if the context lines appear multiple times in a file (e.g., a file with repeated } / } / } closings). Applying an edit to the wrong occurrence silently corrupts the file. The model does not know this happened unless it reads the file again. Uniqueness validation — requiring that the context lines are unique in the file — prevents this at the cost of more read calls before editing.

OpenCode waits 3 seconds for LSP diagnostics after applying. Some language servers (TypeScript’s tsserver in particular) need longer to typecheck large projects. If the tool returns before typecheck completes, the model sees “no errors” and proceeds, even though errors would appear a few seconds later. The touchFile mechanism and 150ms debounce help, but there is no guarantee the LSP has caught up.

When a patch renames A.rs to B.rs, the sequence must be: write new content to B.rs, then delete A.rs. Writing B.rs first is safe even if B.rs already exists (it will be overwritten). But if the tool deletes A.rs first and the write to B.rs fails, the file is gone. Always write before deleting on rename operations.

OpenCode computes the full diff before asking for permission (Phase 2 runs before Phase 3). This is deliberate — the permission dialog shows the user an accurate before/after preview. The cost is that if the user denies the patch, the computation work (fuzzy matching, diffing) was wasted. For large patches, this computation can take hundreds of milliseconds. Accept this cost; showing users accurate diffs is worth it.

The @@ hunk anchor followed by context lines must match the actual file content. If the model read a stale version of the file (e.g., a previous tool call modified it), the context lines will not match and the patch will fail. The model must always re-read files before constructing patches if there is any chance the file was modified since the last read.

Codex’s shell or apply_patch tools can delete files. If the model deletes the wrong file, the only recovery is the ghost commit mechanism (documented in Ghost Commits). There is no built-in “undo” at the tool level — deletion is immediate and permanent unless a snapshot exists. The approval flow is the last line of defense.


Architecture: Two-Phase Apply with Staging

Section titled “Architecture: Two-Phase Apply with Staging”
#[derive(Deserialize, JsonSchema)]
pub struct ApplyPatchParams {
/// The full patch text. Must begin with '*** Begin Patch'.
pub patch_text: String,
}
pub async fn execute(params: ApplyPatchParams, ctx: &ToolContext) -> ToolResult {
// Phase 1: Parse
let patch = parse_patch(&params.patch_text)?;
// Phase 2: Compute new contents and diffs (before permission)
let plan = compute_plan(&patch, ctx).await?;
// Phase 3: Permission
ctx.ask(Permission::Edit {
files: plan.file_summaries(),
diff: plan.total_diff(),
}).await?;
// Phase 4: Apply with staging
let snapshot = Snapshot::create(ctx.git_repo(), &plan.paths()).await?;
apply_with_rollback(&plan, snapshot).await?;
// Phase 5: LSP diagnostics
let diagnostics = collect_diagnostics(&plan.paths(), ctx.lsp()).await;
Ok(ToolOutput::new(plan.summary(), diagnostics))
}

Unlike OpenCode’s write-then-hope approach, OpenOxide should write to a staging directory first:

async fn apply_with_rollback(plan: &ApplyPlan, snapshot: Snapshot) -> Result<()> {
let staged: Vec<(PathBuf, Vec<u8>)> = plan.compute_staged_writes()?;
// Atomic-ish: write all files, then commit
for (path, content) in &staged {
tokio::fs::write(path, content).await?;
}
// If any write failed, restore from snapshot
// (the snapshot is a git ghost commit per [Ghost Commits] architecture)
Ok(())
}

In practice, filesystem writes are not transactional. The best approximation is: collect all (path, new_content) pairs first, then write in a loop, tracking which writes succeeded. On any failure, restore succeeded-so-far paths from the ghost snapshot.

Before accepting a patch, verify that each hunk’s context lines match exactly one location in the target file:

fn validate_uniqueness(original: &str, context_lines: &[&str]) -> Result<usize> {
let matches: Vec<usize> = find_all_occurrences(original, context_lines);
match matches.len() {
0 => Err(PatchError::ContextNotFound),
1 => Ok(matches[0]),
n => Err(PatchError::AmbiguousContext { count: n }),
}
}

Return a structured error with the count of matches so the model can add more context lines to disambiguate.

Patch applied.
[M] src/main.rs (+12 -3)
[A] src/lib.rs (+48)
[D] src/old.rs
<diagnostics>
<file path="src/main.rs">
<error line="15">mismatched types: expected i32, found &str</error>
</file>
</diagnostics>
[dependencies]
similar = "2" # diff computation (unified diff generation)
git2 = "0.18" # ghost snapshot creation before apply
tokio = { features = ["fs"] }
serde = { features = ["derive"] }
schemars = "0.8"