telos_agent/tools/builtin/
file_edit.rs1use 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
19pub 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 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 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 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 let mut perms = std::fs::metadata(&file).unwrap().permissions();
211 perms.set_mode(0o755);
212 std::fs::set_permissions(&file, perms).unwrap();
213
214 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}