Skip to main content

phi_core/tools/
edit.rs

1//! Edit tool — surgical search/replace edits on files.
2//!
3//! This is the most important tool for coding agents. Instead of rewriting
4//! entire files, the agent specifies exact text to find and replace.
5//! Modeled after Claude Code's Edit tool and Aider's search/replace blocks.
6/*
7ARCHITECTURE: EditFileTool — precise surgical edits vs full file rewrites
8
9Why a search/replace tool instead of write_file?
10  - Safety: the agent must prove it knows the current file state by providing
11    exact `old_text` that must exist in the file. If the file changed since the
12    agent read it, the edit fails with a clear error — no silent corruption.
13  - Efficiency: for large files, the agent only needs to send the changed region,
14    not the entire file contents. Reduces both LLM context usage and API costs.
15  - Clarity: the diff between old_text and new_text is the "minimal patch" — easy
16    for humans to review.
17
18The tool verifies exactly ONE occurrence of old_text. If it appears multiple times,
19it returns an error asking the agent to provide more context (more surrounding lines)
20to disambiguate. This prevents accidental double-edits.
21
22RUST QUIRK: `matches!()` on a count
23  After counting occurrences with `.matches(old_text).count()`, we match on:
24    0 → not found error
25    1 → do the replacement
26    _ → ambiguous (multiple occurrences) error
27*/
28
29use crate::types::*;
30use async_trait::async_trait;
31
32/// Surgical file editing via exact text search/replace.
33pub struct EditFileTool;
34
35impl Default for EditFileTool {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl EditFileTool {
42    pub fn new() -> Self {
43        Self
44    }
45}
46
47#[async_trait]
48impl AgentTool for EditFileTool {
49    fn name(&self) -> &str {
50        "edit_file"
51    }
52
53    fn label(&self) -> &str {
54        "Edit File"
55    }
56
57    fn description(&self) -> &str {
58        "Make a surgical edit to a file by specifying exact text to find and replace. The old_text must match exactly (including whitespace and indentation). For creating new files, use write_file instead."
59    }
60
61    fn parameters_schema(&self) -> serde_json::Value {
62        serde_json::json!({
63            "type": "object",
64            "properties": {
65                "path": {
66                    "type": "string",
67                    "description": "File path to edit"
68                },
69                "old_text": {
70                    "type": "string",
71                    "description": "Exact text to find (must match exactly, including whitespace)"
72                },
73                "new_text": {
74                    "type": "string",
75                    "description": "Text to replace it with"
76                }
77            },
78            "required": ["path", "old_text", "new_text"]
79        })
80    }
81
82    async fn execute(
83        &self,
84        params: serde_json::Value, // LLM INPUT — expects `{"path", "old_text", "new_text"}` — the surgical edit spec
85        ctx: ToolContext,          // SYSTEM ENV — ctx.cancel checked once before reading the file
86    ) -> Result<ToolResult, ToolError> {
87        let cancel = ctx.cancel;
88        let path = params["path"]
89            .as_str()
90            .ok_or_else(|| ToolError::InvalidArgs("missing 'path' parameter".into()))?;
91        let old_text = params["old_text"]
92            .as_str()
93            .ok_or_else(|| ToolError::InvalidArgs("missing 'old_text' parameter".into()))?;
94        let new_text = params["new_text"]
95            .as_str()
96            .ok_or_else(|| ToolError::InvalidArgs("missing 'new_text' parameter".into()))?;
97
98        if cancel.is_cancelled() {
99            return Err(ToolError::Cancelled);
100        }
101
102        // Read existing file
103        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
104            ToolError::Failed(format!(
105                "Cannot read {}: {}. Use write_file to create new files.",
106                path, e
107            ))
108        })?;
109
110        // Find the old text
111        let match_count = content.matches(old_text).count();
112
113        if match_count == 0 {
114            // Provide helpful error with context
115            let suggestion = find_similar_text(&content, old_text);
116            let hint = if let Some(similar) = suggestion {
117                format!(
118                    "\n\nDid you mean:\n```\n{}\n```\nMake sure old_text matches exactly, including whitespace and indentation.",
119                    similar
120                )
121            } else {
122                "\n\nTip: Use read_file to see the current file contents, then copy the exact text you want to replace.".into()
123            };
124
125            return Err(ToolError::Failed(format!(
126                "old_text not found in {}.{}",
127                path, hint
128            )));
129        }
130
131        if match_count > 1 {
132            return Err(ToolError::Failed(format!(
133                "old_text matches {} locations in {}. Include more surrounding context to make the match unique.",
134                match_count, path
135            )));
136        }
137
138        // Perform the replacement
139        let new_content = content.replacen(old_text, new_text, 1);
140
141        tokio::fs::write(path, &new_content)
142            .await
143            .map_err(|e| ToolError::Failed(format!("Cannot write {}: {}", path, e)))?;
144
145        // Show what changed
146        let old_lines = old_text.lines().count();
147        let new_lines = new_text.lines().count();
148        let diff_summary = if old_text == new_text {
149            "No changes (old_text == new_text)".into()
150        } else {
151            format!(
152                "Replaced {} line{} with {} line{} in {}",
153                old_lines,
154                if old_lines == 1 { "" } else { "s" },
155                new_lines,
156                if new_lines == 1 { "" } else { "s" },
157                path
158            )
159        };
160
161        Ok(ToolResult {
162            content: vec![Content::Text { text: diff_summary }],
163            details: serde_json::json!({
164                "path": path,
165                "old_lines": old_lines,
166                "new_lines": new_lines,
167            }),
168            child_loop_id: None,
169        })
170    }
171}
172
173/// Try to find similar text in the file (fuzzy match for better error messages).
174fn find_similar_text(
175    content: &str, // HAYSTACK — full current file contents to search through
176    target: &str, // NEEDLE   — the old_text the agent provided (may have whitespace/indentation differences)
177) -> Option<String> {
178    let target_trimmed = target.trim();
179    if target_trimmed.is_empty() {
180        return None;
181    }
182
183    // Try to find the first line of target in the content
184    let first_line = target_trimmed.lines().next()?;
185    let first_line_trimmed = first_line.trim();
186
187    if first_line_trimmed.is_empty() {
188        return None;
189    }
190
191    // Search for lines containing the first line (case-sensitive)
192    let lines: Vec<&str> = content.lines().collect();
193    for (i, line) in lines.iter().enumerate() {
194        if line.contains(first_line_trimmed) {
195            // Return a few lines of context
196            let start = i;
197            let target_line_count = target_trimmed.lines().count();
198            let end = (i + target_line_count + 1).min(lines.len());
199            return Some(lines[start..end].join("\n"));
200        }
201    }
202
203    None
204}