1use once_cell::sync::Lazy;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6use std::sync::Arc;
7use steer_macros::tool;
8use tokio::fs;
9use tokio::sync::Mutex;
10use tokio_util::sync::CancellationToken;
11
12use crate::context::ExecutionContext;
13use crate::error::ToolError;
14use crate::result::EditResult;
15
16static FILE_LOCKS: Lazy<Mutex<HashMap<String, Arc<Mutex<()>>>>> =
18 Lazy::new(|| Mutex::new(HashMap::new()));
19
20async fn get_file_lock(file_path: &str) -> Arc<Mutex<()>> {
21 let mut locks_map_guard = FILE_LOCKS.lock().await;
22 locks_map_guard
23 .entry(file_path.to_string())
24 .or_insert_with(|| Arc::new(Mutex::new(())))
25 .clone()
26}
27
28#[derive(Deserialize, Debug, JsonSchema, Clone)]
29pub struct SingleEditOperation {
30 pub old_string: String,
34 pub new_string: String,
36}
37
38async fn perform_edit_operations(
42 file_path_str: &str,
43 operations: &[SingleEditOperation],
44 token: Option<&CancellationToken>,
45 tool_name_for_errors: &str,
46) -> Result<(String, usize, bool), ToolError> {
47 if let Some(t) = &token {
48 if t.is_cancelled() {
49 return Err(ToolError::Cancelled(tool_name_for_errors.to_string()));
50 }
51 }
52
53 let path = Path::new(file_path_str);
54 let mut current_content: String;
55 let mut file_created_this_op = false;
56
57 match fs::read_to_string(path).await {
58 Ok(content_from_file) => {
59 current_content = content_from_file;
60 }
61 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
62 if operations.is_empty() {
63 return Err(ToolError::execution(
64 tool_name_for_errors,
65 format!(
66 "File {file_path_str} does not exist and no operations provided to create it."
67 ),
68 ));
69 }
70 let first_op = &operations[0];
71 if first_op.old_string.is_empty() {
72 if let Some(parent) = path.parent() {
73 if !fs::metadata(parent)
74 .await
75 .map(|m| m.is_dir())
76 .unwrap_or(false)
77 {
78 if let Some(t) = &token {
79 if t.is_cancelled() {
80 return Err(ToolError::Cancelled(tool_name_for_errors.to_string()));
81 }
82 }
83 fs::create_dir_all(parent).await.map_err(|e| {
84 ToolError::io(
85 tool_name_for_errors,
86 format!("Failed to create directory {}: {}", parent.display(), e),
87 )
88 })?;
89 }
90 }
91 current_content = first_op.new_string.clone();
92 file_created_this_op = true;
93 } else {
94 return Err(ToolError::io(
95 tool_name_for_errors,
96 format!(
97 "File {file_path_str} not found, and the first/only operation's old_string is not empty (required for creation)."
98 ),
99 ));
100 }
101 }
102 Err(e) => {
103 return Err(ToolError::io(
105 tool_name_for_errors,
106 format!("Failed to read file {file_path_str}: {e}"),
107 ));
108 }
109 }
110
111 if operations.is_empty() {
112 return Ok((current_content, 0, false));
114 }
115
116 let mut edits_applied_count = 0;
117 for (index, edit_op) in operations.iter().enumerate() {
118 if let Some(t) = &token {
119 if t.is_cancelled() {
120 return Err(ToolError::Cancelled(tool_name_for_errors.to_string()));
121 }
122 }
123
124 if edit_op.old_string.is_empty() {
125 if index == 0 && file_created_this_op {
126 } else if index == 0 && operations.len() == 1 && edit_op.old_string.is_empty() {
128 current_content = edit_op.new_string.clone();
130 if !file_created_this_op {
131 file_created_this_op = true; }
134 } else {
135 return Err(ToolError::execution(
136 tool_name_for_errors,
137 format!(
138 "Edit #{} for file {} has an empty old_string. This is only allowed for the first operation if the file is being created or for a single operation to overwrite the file.",
139 index + 1,
140 file_path_str
141 ),
142 ));
143 }
144 } else {
145 let occurrences = current_content.matches(&edit_op.old_string).count();
147 if occurrences == 0 {
148 return Err(ToolError::execution(
149 tool_name_for_errors,
150 format!(
151 "For edit #{}, string not found in file {} (after {} previous successful edits). String to find (first 50 chars): '{}'",
152 index + 1,
153 file_path_str,
154 edits_applied_count,
155 edit_op.old_string.chars().take(50).collect::<String>()
156 ),
157 ));
158 }
159 if occurrences > 1 {
160 return Err(ToolError::execution(
161 tool_name_for_errors,
162 format!(
163 "For edit #{}, found {} occurrences of string in file {} (after {} previous successful edits). String to find (first 50 chars): '{}'. Please provide more context.",
164 index + 1,
165 occurrences,
166 file_path_str,
167 edits_applied_count,
168 edit_op.old_string.chars().take(50).collect::<String>()
169 ),
170 ));
171 }
172 current_content = current_content.replace(&edit_op.old_string, &edit_op.new_string);
173 }
174 edits_applied_count += 1;
175 }
176 Ok((current_content, edits_applied_count, file_created_this_op))
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
180pub struct EditParams {
181 pub file_path: String,
183 pub old_string: String,
185 pub new_string: String,
187}
188
189tool! {
190 EditTool {
191 params: EditParams,
192 output: EditResult,
193 variant: Edit,
194 description: r#"This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the replace tool to overwrite files.
195
196Before using this tool:
197
1981. Use the View tool to understand the file's contents and context
199
2002. Verify the directory path is correct (only applicable when creating new files):
201 - Use the LS tool to verify the parent directory exists and is the correct location
202
203To make a file edit, provide the following:
2041. file_path: The absolute path to the file to modify (must be absolute, not relative)
2052. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
2063. new_string: The edited text to replace the old_string
207
208The tool will replace ONE occurrence of old_string with new_string in the specified file.
209
210CRITICAL REQUIREMENTS FOR USING THIS TOOL:
211
2121. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
213 - Include AT LEAST 3-5 lines of context BEFORE the change point
214 - Include AT LEAST 3-5 lines of context AFTER the change point
215 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
216
2172. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
218 - Make separate calls to this tool for each instance
219 - Each call must uniquely identify its specific instance using extensive context
220
2213. VERIFICATION: Before using this tool:
222 - Check how many instances of the target text exist in the file
223 - If multiple instances exist, gather enough context to uniquely identify each one
224 - Plan separate tool calls for each instance
225
226WARNING: If you do not follow these requirements:
227 - The tool will fail if old_string matches multiple locations
228 - The tool will fail if old_string doesn't match exactly (including whitespace)
229 - You may change the wrong instance if you don't include enough context
230
231When making edits:
232 - Ensure the edit results in idiomatic, correct code
233 - Do not leave the code in a broken state
234 - Always use absolute file paths (starting with /)
235
236If you want to create a new file, use:
237 - A new file path, including dir name if needed
238 - An empty old_string
239 - The new file's contents as new_string
240
241Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each."#,
242 name: "edit_file",
243 require_approval: true
244 }
245
246 async fn run(
247 _tool: &EditTool,
248 params: EditParams,
249 context: &ExecutionContext,
250 ) -> Result<EditResult, ToolError> {
251 let file_lock = get_file_lock(¶ms.file_path).await;
252 let _lock_guard = file_lock.lock().await;
253
254 let operation = SingleEditOperation {
255 old_string: params.old_string,
256 new_string: params.new_string,
257 };
258
259 match perform_edit_operations(¶ms.file_path, &[operation], Some(&context.cancellation_token), EDIT_TOOL_NAME).await {
260 Ok((final_content, num_ops, created_or_overwritten)) => {
261 if created_or_overwritten || num_ops > 0 { if context.cancellation_token.is_cancelled() { return Err(ToolError::Cancelled(EDIT_TOOL_NAME.to_string())); }
266 fs::write(Path::new(¶ms.file_path), &final_content)
267 .await
268 .map_err(|e| ToolError::io(EDIT_TOOL_NAME, format!("Failed to write file {}: {}", params.file_path, e)))?;
269
270 if created_or_overwritten {
271 Ok(EditResult {
273 file_path: params.file_path.clone(),
274 changes_made: num_ops,
275 file_created: true,
276 old_content: None,
277 new_content: Some(final_content),
278 })
279 } else {
280 Ok(EditResult {
281 file_path: params.file_path.clone(),
282 changes_made: num_ops,
283 file_created: false,
284 old_content: None,
285 new_content: Some(final_content),
286 })
287 }
288 } else {
289 Ok(EditResult {
292 file_path: params.file_path.clone(),
293 changes_made: 0,
294 file_created: false,
295 old_content: None,
296 new_content: None,
297 })
298 }
299 }
300 Err(e) => Err(e),
301 }
302 }
303}
304
305pub mod multi_edit {
306 use super::*;
307 use crate::result::MultiEditResult;
308
309 #[derive(Deserialize, Debug, JsonSchema)]
310 pub struct MultiEditParams {
311 pub file_path: String,
313 pub edits: Vec<SingleEditOperation>,
315 }
316
317 tool! {
318 MultiEditTool {
319 params: MultiEditParams,
320 output: MultiEditResult,
321 variant: Edit,
322 description: format!(r#"This is a tool for making multiple edits to a single file in one operation. It is built on top of the {} tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the {} tool when you need to make multiple edits to the same file.
323
324Before using this tool:
325
3261. Use the View tool to understand the file's contents and context
3272. Verify the directory path is correct
328
329To make multiple file edits, provide the following:
3301. file_path: The absolute path to the file to modify (must be absolute, not relative)
3312. edits: An array of edit operations to perform, where each edit contains:
332 - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
333 - new_string: The edited text to replace the old_string
334
335IMPORTANT:
336- All edits are applied in sequence, in the order they are provided
337- Each edit operates on the result of the previous edit
338- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
339- This tool is ideal when you need to make several changes to different parts of the same file
340
341CRITICAL REQUIREMENTS:
3421. All edits follow the same requirements as the single Edit tool
3432. The edits are atomic - either all succeed or none are applied
3443. Plan your edits carefully to avoid conflicts between sequential operations
345
346WARNING: Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find.
347
348When making edits:
349- Ensure all edits result in idiomatic, correct code
350- Do not leave the code in a broken state
351- Always use absolute file paths (starting with /)
352
353If you want to create a new file, use:
354- A new file path, including dir name if needed
355- First edit: empty old_string and the new file's contents as new_string
356- Subsequent edits: normal edit operations on the created content
357"#, EDIT_TOOL_NAME, EDIT_TOOL_NAME),
358 name: "multi_edit_file",
359 require_approval: true
360 }
361
362 async fn run(
363 _tool: &MultiEditTool,
364 params: MultiEditParams,
365 context: &ExecutionContext,
366 ) -> Result<MultiEditResult, ToolError> {
367 let file_lock = super::get_file_lock(¶ms.file_path).await;
368 let _lock_guard = file_lock.lock().await;
369
370 if params.edits.is_empty() {
371 let path = Path::new(¶ms.file_path);
373 if !fs::metadata(path).await.map(|m| m.is_file()).unwrap_or(false) {
374 return Err(ToolError::execution(
375 MULTI_EDIT_TOOL_NAME,
376 format!("File {} does not exist and no edit operations provided to create or modify it.", params.file_path),
377 ));
378 }
379 return Ok(MultiEditResult(EditResult {
380 file_path: params.file_path.clone(),
381 changes_made: 0,
382 file_created: false,
383 old_content: None,
384 new_content: None,
385 }));
386 }
387
388 match super::perform_edit_operations(¶ms.file_path, ¶ms.edits, Some(&context.cancellation_token), MULTI_EDIT_TOOL_NAME).await {
389 Ok((final_content, num_ops_processed, file_was_created)) => {
390 if num_ops_processed > 0 || file_was_created { if context.cancellation_token.is_cancelled() { return Err(ToolError::Cancelled(MULTI_EDIT_TOOL_NAME.to_string())); }
396 fs::write(Path::new(¶ms.file_path), &final_content)
397 .await
398 .map_err(|e| ToolError::io(MULTI_EDIT_TOOL_NAME, format!("Failed to write file {}: {}", params.file_path, e)))?;
399
400 if file_was_created {
401 Ok(MultiEditResult(EditResult {
402 file_path: params.file_path.clone(),
403 changes_made: num_ops_processed,
404 file_created: true,
405 old_content: None,
406 new_content: Some(final_content.clone()),
407 }))
408 } else { Ok(MultiEditResult(EditResult {
410 file_path: params.file_path.clone(),
411 changes_made: num_ops_processed,
412 file_created: false,
413 old_content: None,
414 new_content: Some(final_content.clone()),
415 }))
416 }
417 } else {
418 Ok(MultiEditResult(EditResult {
421 file_path: params.file_path.clone(),
422 changes_made: 0,
423 file_created: false,
424 old_content: None,
425 new_content: None,
426 }))
427 }
428 }
429 Err(e) => Err(e), }
431 }
432 }
433}