# Replace `patch_file` with `edit_file`

## Motivation

The current `patch_file` tool requires LLMs to produce a custom patch format with
`*** Begin Patch`, `@@ context @@` anchors, and `+/-/space` line prefixes. Local
LLMs struggle with this. The replacement `edit_file` tool uses simple
`old_string`/`new_string` parameters — no custom format to learn.

## File changes

### 1. Create `edit.py`

New module (~100 lines) with a single public function:

```python
def replace(content: str, old_string: str, new_string: str, replace_all: bool = False) -> str
```

**Matching strategies (tried in order):**

1. **Exact** — `content.find(old_string)`
2. **Line-trimmed** — sliding window over lines, comparing `.strip()` per line
3. **Unicode-normalized** — smart quotes → ASCII, em dashes → `-`, ellipsis → `...`
   (moved from `patch.py`'s `_normalize_unicode`)

No Levenshtein / block-anchor matching — all passes are deterministic.
Fuzzy passes only kick in when exact fails, and each fuzzy pass still checks for
uniqueness (unless `replace_all` is set).

**Error cases (all raise `ValueError`):**

| Condition | Message |
|-----------|---------|
| `old_string == new_string` | `"no changes"` |
| Not found in any pass | `"not found"` |
| Multiple matches and `replace_all=False` | `"multiple matches"` |

**Ambiguity check:** after finding a match via any fuzzy pass, re-scan the full
content with the same strategy to confirm uniqueness (unless `replace_all`).

### 2. Update `tools.py`

Three changes in this file:

**a) Replace schema** (lines 74–99)

Remove the `patch_file` tool entry from `TOOLS`. Add `edit_file`:

```python
{
    "type": "function",
    "function": {
        "name": "edit_file",
        "description": (
            "Make a targeted edit to an existing file by replacing old_string with new_string. "
            "For creating new files, use write_file instead."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "file_path": {"type": "string", "description": "Path to the file to edit."},
                "old_string": {"type": "string", "description": "The exact text to find and replace."},
                "new_string": {"type": "string", "description": "The replacement text."},
                "replace_all": {"type": "boolean", "description": "Replace all occurrences.", "default": False},
            },
            "required": ["file_path", "old_string", "new_string"],
        },
    },
}
```

Note: no empty-`old_string`-creates-file behavior. File creation stays in
`write_file` where it belongs. This avoids accidental overwrites.

**b) Replace `_patch_file` function** (lines 603–607)

New function `_edit_file(file_path, old_string, new_string, base_dir, replace_all=False)`:

- Reads the file, calls `edit.replace()`, writes the result back
- File must exist (returns `"error: ..."` otherwise)
- Errors from `replace()` returned as `"error: ..."` strings (existing convention)

**c) Update `dispatch()`** (lines 861–862)

Replace the `"patch_file"` branch with `"edit_file"` routing to `_edit_file()`.

### 3. Update `system_prompt.txt`

**Remove** the "Patch format" section (lines 34–58).

**Replace** with:

```
# Editing files

Use `edit_file` for targeted modifications. Pass the exact text to change as
`old_string` and the replacement as `new_string`.

- Copy `old_string` from `read_file` output (without line numbers).
- If multiple matches: include more context to make it unique, or set `replace_all`.
- Prefer `edit_file` over `write_file` for modifying existing files.
- Use `write_file` to create new files.
- Each call handles one edit. For multiple changes, make multiple calls.
```

**Update** the tools list (line 10) to mention `edit_file` instead of `patch_file`.

### 4. Create `tests/test_edit.py`

New test file (replaces `tests/test_patch.py`) with these test classes:

| Class | What it covers |
|-------|---------------|
| `TestExactMatch` | Simple replacement, multiline, start/end of file |
| `TestReplaceAll` | Replaces all occurrences when flag is set |
| `TestMultipleMatchesError` | Ambiguous match errors; adding context resolves it |
| `TestNotFound` | String not in file |
| `TestNoOp` | `old_string == new_string` raises error |
| `TestValidation` | Empty `old_string` → error; missing file → error |
| `TestFuzzyMatching` | Whitespace tolerance, unicode normalization |
| `TestEdgeCases` | Empty file, no trailing newline |

### 5. Update `tests/test_tools.py`

- Remove `from patch import apply_patch, parse_patch` (line 13)
- Remove `TestPatchFilePositive` (lines 80–126) → add `TestEditFilePositive`
  testing via `dispatch("edit_file", ...)`
- Remove `TestPatchEdgeCases` (lines 205–265) → covered by `test_edit.py`
- Remove `TestSymlinkDeletion` (lines 310–349) → no longer relevant
- Add dispatch-level error formatting tests: `_edit_file` returns `"error: ..."`
  for missing files, empty `old_string`, not-found, and multiple-match cases
- Update file docstring (line 1)

### 6. Delete `patch.py` and `tests/test_patch.py`

Fully replaced by `edit.py` and `tests/test_edit.py`.

### 7. Update `pyproject.toml`

Replace `patch.py` with `edit.py` in the `only-include` list (line 20):

```toml
only-include = ["agent.py", "tools.py", "edit.py"]
```

### 8. Update `CLAUDE.md` and `README.md`

- **CLAUDE.md** line 30–31: replace `patch_file` references with `edit_file`
  in the tools.py and patch.py architecture descriptions. Remove the patch.py
  bullet entirely, add an edit.py bullet.
- **README.md** lines 89–93: replace `patch_file` tool description with
  `edit_file`. Update `write_file` description (it can overwrite, since
  `edit_file` no longer handles creation).

## Dropped operations

- **Delete file** and **move/rename**: removed from the edit tool. The agent can
  use `run_command` with `rm`/`mv` when `--allowed-commands` is configured.
  Without `--allowed-commands`, these operations are unavailable — this is an
  intentional simplification (the tool was already optional for command execution).

## Verification

```sh
# Run the relevant tests
uv run python -m pytest tests/test_edit.py tests/test_tools.py -v
```
