Skip to main content

telos_agent/tools/builtin/
file_edit.rs

1//! `Edit` tool — exact-match find-and-replace on a single UTF-8 text file.
2//!
3//! Requires the `old` string to occur **exactly once** in the file. This is a
4//! deliberate guard against ambiguous edits — if the model wants to replace a
5//! common token it must include enough surrounding context to make the match
6//! unique.
7
8use async_trait::async_trait;
9use serde_json::{Value, json};
10
11use crate::error::AgentError;
12use crate::tools::api::{PermissionDecision, Tool, ToolContext, ToolDefinition, ToolOutput};
13
14use super::{
15    canonicalize_within_cwd, ensure_file_was_read_and_unchanged, modified_timestamp_ms,
16    optional_bool, required_string_any, resolve_workspace_path,
17};
18
19/// Built-in file-edit tool. Performs a single, unambiguous string replacement.
20pub struct FileEditTool;
21
22#[async_trait]
23impl Tool for FileEditTool {
24    fn definition(&self) -> ToolDefinition {
25        ToolDefinition {
26            name: "Edit".into(),
27            description: "Replace an exact string in a UTF-8 text file. Existing files must be read first with Read.".into(),
28            input_schema: json!({
29                "type": "object",
30                "properties": {
31                    "file_path": { "type": "string", "description": "Path to the file to edit, absolute or relative to cwd." },
32                    "old_string": { "type": "string", "description": "Exact string to replace. Use an empty string only to create a new file." },
33                    "new_string": { "type": "string", "description": "Replacement string." },
34                    "replace_all": { "type": "boolean", "description": "Replace every occurrence. Defaults to false." }
35                },
36                "required": ["file_path", "old_string", "new_string"]
37            }),
38        }
39    }
40
41    fn prompt_text(&self) -> Option<&'static str> {
42        Some(
43            "Use Edit to make precise, exact-match replacements in a UTF-8 text file. The file must have been Read first. \
44`old_string` must match exactly once unless `replace_all` is true. Include enough surrounding context to make `old_string` unique. \
45Use an empty `old_string` only to create a new file. Do not use Edit on binary files or Jupyter notebooks.",
46        )
47    }
48
49    async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
50        required_string_any(arguments, &["file_path", "path"])?;
51        required_string_any(arguments, &["old_string", "old"])?;
52        required_string_any(arguments, &["new_string", "new"])?;
53        Ok(())
54    }
55
56    async fn check_permission(
57        &self,
58        _arguments: &Value,
59        _context: &ToolContext,
60    ) -> Result<PermissionDecision, AgentError> {
61        Ok(PermissionDecision::Ask { reason: "file edit requires approval".into() })
62    }
63
64    async fn invoke(
65        &self,
66        arguments: Value,
67        context: ToolContext,
68    ) -> Result<ToolOutput, AgentError> {
69        let input_path = required_string_any(&arguments, &["file_path", "path"])?;
70        let path = resolve_workspace_path(&context.cwd, input_path)?;
71        let path = canonicalize_within_cwd(&context.cwd, &path).await?;
72        if path.extension().and_then(|ext| ext.to_str()) == Some("ipynb") {
73            return Err(AgentError::ToolExecution {
74                tool: "Edit".into(),
75                message: "File is a Jupyter Notebook. Use a notebook-aware edit tool instead."
76                    .into(),
77            });
78        }
79        let old = required_string_any(&arguments, &["old_string", "old"])?;
80        let new = required_string_any(&arguments, &["new_string", "new"])?;
81        if old == new {
82            return Err(AgentError::ToolExecution {
83                tool: "Edit".into(),
84                message: "No changes to make: old_string and new_string are exactly the same."
85                    .into(),
86            });
87        }
88        let replace_all = optional_bool(&arguments, "replace_all", false);
89
90        // Capture pre-existing permissions so we can restore them after writing.
91        let existing_metadata = tokio::fs::metadata(&path).await.ok();
92        let content = match tokio::fs::read_to_string(&path).await {
93            Ok(content) => content,
94            Err(err) if err.kind() == std::io::ErrorKind::NotFound && old.is_empty() => {
95                String::new()
96            }
97            Err(err) => {
98                return Err(AgentError::ToolExecution {
99                    tool: "Edit".into(),
100                    message: err.to_string(),
101                });
102            }
103        };
104
105        if !old.is_empty() {
106            ensure_file_was_read_and_unchanged("Edit", &context, &path, &content).await?;
107        } else if !content.trim().is_empty() {
108            return Err(AgentError::ToolExecution {
109                tool: "Edit".into(),
110                message: "Cannot create new file - file already exists.".into(),
111            });
112        }
113
114        // Reject ambiguous (0 or >1) matches so the model is forced to widen
115        // the snippet rather than silently editing the wrong location.
116        let count = if old.is_empty() { 1 } else { content.matches(old).count() };
117        if count == 0 {
118            return Err(AgentError::ToolExecution {
119                tool: "Edit".into(),
120                message: format!("String to replace not found in file.\nString: {old}"),
121            });
122        }
123        if count > 1 && !replace_all {
124            return Err(AgentError::ToolExecution {
125                tool: "Edit".into(),
126                message: format!(
127                    "Found {count} matches of the string to replace, but replace_all is false. Provide more context to uniquely identify the instance or set replace_all to true.\nString: {old}"
128                ),
129            });
130        }
131        let updated = if old.is_empty() {
132            new.to_string()
133        } else if replace_all {
134            content.replace(old, new)
135        } else {
136            content.replacen(old, new, 1)
137        };
138        if let Some(parent) = path.parent() {
139            tokio::fs::create_dir_all(parent).await.map_err(|err| AgentError::ToolExecution {
140                tool: "Edit".into(),
141                message: err.to_string(),
142            })?;
143        }
144        tokio::fs::write(&path, updated).await.map_err(|err| AgentError::ToolExecution {
145            tool: "Edit".into(),
146            message: err.to_string(),
147        })?;
148        // Preserve the original mode bits when editing an existing file.
149        if let Some(metadata) = existing_metadata
150            && let Err(err) = tokio::fs::set_permissions(&path, metadata.permissions()).await
151        {
152            return Err(AgentError::ToolExecution {
153                tool: "Edit".into(),
154                message: format!("failed to restore file permissions: {err}"),
155            });
156        }
157        let updated_content = tokio::fs::read_to_string(&path).await.map_err(|err| {
158            AgentError::ToolExecution { tool: "Edit".into(), message: err.to_string() }
159        })?;
160        let timestamp_ms = modified_timestamp_ms(&path).await?;
161        context.read_file_state.lock().await.insert(
162            path.clone(),
163            crate::tools::api::FileReadRecord {
164                content: updated_content,
165                timestamp_ms,
166                is_partial_view: false,
167                offset: None,
168                limit: None,
169            },
170        );
171        Ok(ToolOutput::json(json!({
172            "file_path": input_path,
173            "path": path,
174            "replaced": true,
175            "replace_all": replace_all,
176        })))
177    }
178}
179
180#[cfg(all(test, unix))]
181mod tests {
182    use super::*;
183    use serde_json::json;
184    use std::collections::HashMap;
185    use std::os::unix::fs::PermissionsExt;
186
187    fn ctx(cwd: std::path::PathBuf) -> ToolContext {
188        ToolContext {
189            session_id: "test".into(),
190            turn_id: 1,
191            tool_call_id: None,
192            cwd,
193            env: HashMap::new(),
194            messages: std::sync::Arc::new(vec![]),
195            progress: None,
196            read_file_state: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
197            timeout: None,
198            max_file_read_bytes: usize::MAX,
199        }
200    }
201
202    #[tokio::test]
203    async fn preserves_file_permissions() {
204        let dir = std::env::temp_dir().join("tiny_agent_edit_perms_test");
205        let _ = std::fs::remove_dir_all(&dir);
206        std::fs::create_dir_all(&dir).unwrap();
207        let file = dir.join("script.sh");
208        std::fs::write(&file, "#!/bin/sh\necho old\n").unwrap();
209        // Set an unusual mode so we can detect if it is lost.
210        let mut perms = std::fs::metadata(&file).unwrap().permissions();
211        perms.set_mode(0o755);
212        std::fs::set_permissions(&file, perms).unwrap();
213
214        // Prime the read-file state so the edit is allowed.
215        let context = ctx(dir.clone());
216        context.read_file_state.lock().await.insert(
217            file.clone(),
218            crate::tools::api::FileReadRecord {
219                content: "#!/bin/sh\necho old\n".into(),
220                timestamp_ms: 0,
221                is_partial_view: false,
222                offset: None,
223                limit: None,
224            },
225        );
226
227        let tool = FileEditTool;
228        tool.invoke(
229            json!({
230                "file_path": "script.sh",
231                "old_string": "echo old",
232                "new_string": "echo new"
233            }),
234            context,
235        )
236        .await
237        .unwrap();
238
239        let new_mode = std::fs::metadata(&file).unwrap().permissions().mode();
240        assert_eq!(new_mode & 0o777, 0o755, "file permissions were not preserved");
241
242        let _ = std::fs::remove_dir_all(&dir);
243    }
244}