Skip to main content

atomcode_core/agent/
execute.rs

1//! Phase 4 EXECUTE mode — focused edit execution with minimal context.
2//!
3//! When the model outputs a plan with edit instructions, the agent loop
4//! switches to EXECUTE mode: each instruction is executed in isolation
5//! with only the target file + instruction in context. This prevents
6//! context noise from degrading edit precision.
7
8use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use super::AgentEvent;
12use crate::turn::event::{TurnEvent, TurnResult};
13
14/// A parsed edit instruction from REASON mode output.
15#[derive(Debug, Clone)]
16pub struct EditInstruction {
17    /// Target file path (absolute or relative).
18    pub file: String,
19    /// What to do — the full instruction text for this file.
20    pub instruction: String,
21}
22
23/// Parse EDIT INSTRUCTIONS from a REASON mode response.
24/// Looks for patterns like:
25///   ### File: TopBar.vue
26///   Edit the avatar section...
27///
28///   ### File: App.vue
29///   Import FootBar and add to template...
30///
31/// Also handles numbered list format:
32///   1. TopBar.vue: change the avatar to use local image
33///   2. App.vue: import and register FootBar component
34pub fn parse_edit_instructions(text: &str) -> Vec<EditInstruction> {
35    let mut instructions = Vec::new();
36
37    // Strategy 1: "### File: X" sections
38    let mut current_file: Option<String> = None;
39    let mut current_lines: Vec<String> = Vec::new();
40
41    for line in text.lines() {
42        let trimmed = line.trim();
43
44        // Detect file header: "### File: X.vue" or "**File: X.vue**" or "## X.vue"
45        let file_from_header = extract_file_from_header(trimmed);
46        if let Some(file) = file_from_header {
47            // Save previous instruction
48            if let Some(prev_file) = current_file.take() {
49                let instr = current_lines.join("\n").trim().to_string();
50                if !instr.is_empty() {
51                    instructions.push(EditInstruction {
52                        file: prev_file,
53                        instruction: instr,
54                    });
55                }
56            }
57            current_file = Some(file);
58            current_lines.clear();
59            continue;
60        }
61
62        if current_file.is_some() {
63            current_lines.push(line.to_string());
64        }
65    }
66    // Save last instruction
67    if let Some(file) = current_file {
68        let instr = current_lines.join("\n").trim().to_string();
69        if !instr.is_empty() {
70            instructions.push(EditInstruction {
71                file,
72                instruction: instr,
73            });
74        }
75    }
76
77    // Strategy 2: if no "### File:" headers found, try numbered list
78    // "1. modify TopBar.vue — change avatar"
79    // "2. update App.vue — add FootBar import"
80    if instructions.is_empty() {
81        for line in text.lines() {
82            let trimmed = line.trim();
83            // Match "N. filename.ext ..." or "- filename.ext ..."
84            let rest = if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
85                trimmed.split_once('.').map(|(_, r)| r.trim())
86            } else if trimmed.starts_with("- ") {
87                Some(&trimmed[2..])
88            } else {
89                None
90            };
91
92            if let Some(rest) = rest {
93                // Extract file name from beginning of rest
94                let words: Vec<&str> = rest
95                    .splitn(2, |c: char| {
96                        c == ':' || c == '\u{2014}' || c == '-' || c == ' '
97                    })
98                    .collect();
99                if let Some(first_word) = words.first() {
100                    let first_word = first_word.trim().trim_matches('`');
101                    if is_source_file(first_word) {
102                        let file = first_word.to_string();
103                        let instr = if words.len() > 1 {
104                            words[1..].join(" ").trim().to_string()
105                        } else {
106                            String::new()
107                        };
108                        if !instr.is_empty() {
109                            instructions.push(EditInstruction {
110                                file,
111                                instruction: instr,
112                            });
113                        }
114                    }
115                }
116            }
117        }
118    }
119
120    instructions
121}
122
123/// Extract file name from a header line.
124fn extract_file_from_header(line: &str) -> Option<String> {
125    // "### File: TopBar.vue" or "## File: TopBar.vue"
126    if let Some(rest) = line.strip_prefix("###").or_else(|| line.strip_prefix("##")) {
127        let rest = rest.trim();
128        if let Some(file_part) = rest
129            .strip_prefix("File:")
130            .or_else(|| rest.strip_prefix("file:"))
131        {
132            let file = file_part.trim().trim_matches('`');
133            if is_source_file(file) {
134                return Some(file.to_string());
135            }
136        }
137        // Just "### TopBar.vue"
138        let bare = rest.trim_matches('*').trim();
139        if is_source_file(bare) {
140            return Some(bare.to_string());
141        }
142    }
143
144    // "**TopBar.vue**:" or "**File: TopBar.vue**"
145    if line.starts_with("**") && line.contains("**") {
146        let inner = line
147            .trim_start_matches("**")
148            .split("**")
149            .next()
150            .unwrap_or("");
151        let inner = inner
152            .strip_prefix("File:")
153            .or_else(|| inner.strip_prefix("file:"))
154            .unwrap_or(inner)
155            .trim();
156        if is_source_file(inner) {
157            return Some(inner.to_string());
158        }
159    }
160
161    None
162}
163
164fn is_source_file(s: &str) -> bool {
165    let s = s.trim_matches(|c: char| c == '`' || c == '*' || c == ':' || c == ' ');
166    s.ends_with(".vue")
167        || s.ends_with(".ts")
168        || s.ends_with(".tsx")
169        || s.ends_with(".js")
170        || s.ends_with(".jsx")
171        || s.ends_with(".java")
172        || s.ends_with(".py")
173        || s.ends_with(".rs")
174        || s.ends_with(".go")
175        || s.ends_with(".svelte")
176        || s.ends_with(".html")
177        || s.ends_with(".css")
178}
179
180/// Execute a list of edit instructions in EXECUTE mode.
181/// Each instruction runs in isolation: fresh file read + minimal context.
182/// Returns a summary of results.
183pub async fn execute_instructions(
184    instructions: Vec<EditInstruction>,
185    runner: &mut crate::turn::runner::TurnRunner,
186    event_tx: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
187    working_dir: &std::path::Path,
188) -> (Vec<String>, bool) {
189    let mut summaries = Vec::new();
190    let mut all_success = true;
191
192    for (i, instr) in instructions.iter().enumerate() {
193        // Resolve file path
194        let file_path = if std::path::Path::new(&instr.file).is_absolute() {
195            instr.file.clone()
196        } else {
197            // Search for the file in the working directory
198            match find_file(working_dir, &instr.file) {
199                Some(p) => p.to_string_lossy().to_string(),
200                None => {
201                    summaries.push(format!(
202                        "EXECUTE {}/{}: {} — file not found",
203                        i + 1,
204                        instructions.len(),
205                        instr.file
206                    ));
207                    all_success = false;
208                    continue;
209                }
210            }
211        };
212
213        let _ = event_tx.send(AgentEvent::TextDelta(format!(
214            "\n[EXECUTE {}/{}] Editing {} ...\n",
215            i + 1,
216            instructions.len(),
217            instr.file
218        )));
219
220        // Create a turn event channel for this execute call
221        let (turn_tx, mut turn_rx) = mpsc::unbounded_channel::<TurnEvent>();
222        let cancel = CancellationToken::new();
223
224        let result = runner
225            .run_execute(&file_path, &instr.instruction, &turn_tx, cancel)
226            .await;
227
228        // Forward turn events to agent event channel
229        while let Ok(event) = turn_rx.try_recv() {
230            match event {
231                TurnEvent::ToolCallResult {
232                    ref call_id,
233                    ref name,
234                    ref output,
235                    success,
236                    ..
237                } => {
238                    let _ = event_tx.send(AgentEvent::ToolCallResult {
239                        call_id: call_id.clone(),
240                        name: name.clone(),
241                        output: output.clone(),
242                        success,
243                        duration: std::time::Duration::ZERO,
244                    });
245                }
246                TurnEvent::TextDelta(text) => {
247                    let _ = event_tx.send(AgentEvent::TextDelta(text));
248                }
249                TurnEvent::ReasoningDelta(text) => {
250                    let _ = event_tx.send(AgentEvent::ReasoningDelta(text));
251                }
252                _ => {}
253            }
254        }
255
256        match result {
257            TurnResult::UsedTools { .. } => {
258                summaries.push(format!(
259                    "EXECUTE {}/{}: {} — edited",
260                    i + 1,
261                    instructions.len(),
262                    instr.file
263                ));
264            }
265            TurnResult::Responded { ref text, .. } => {
266                // Model responded with text instead of calling edit_file — likely an error
267                summaries.push(format!(
268                    "EXECUTE {}/{}: {} — no edit (model said: {})",
269                    i + 1,
270                    instructions.len(),
271                    instr.file,
272                    text.chars().take(100).collect::<String>()
273                ));
274                all_success = false;
275            }
276            TurnResult::Failed(ref e) => {
277                summaries.push(format!(
278                    "EXECUTE {}/{}: {} — failed: {}",
279                    i + 1,
280                    instructions.len(),
281                    instr.file,
282                    e
283                ));
284                all_success = false;
285            }
286            TurnResult::Cancelled => {
287                summaries.push(format!(
288                    "EXECUTE {}/{}: {} — cancelled",
289                    i + 1,
290                    instructions.len(),
291                    instr.file
292                ));
293                all_success = false;
294                break;
295            }
296        }
297    }
298
299    (summaries, all_success)
300}
301
302/// Find a file by name in the working directory tree.
303fn find_file(dir: &std::path::Path, name: &str) -> Option<std::path::PathBuf> {
304    // Check direct path first
305    let direct = dir.join(name);
306    if direct.exists() {
307        return Some(direct);
308    }
309
310    // Walk the tree
311    let walker = ignore::WalkBuilder::new(dir)
312        .hidden(true)
313        .git_ignore(true)
314        .max_depth(Some(8))
315        .build();
316
317    for entry in walker {
318        if let Ok(e) = entry {
319            if e.file_type().map(|t| t.is_file()).unwrap_or(false) {
320                if let Some(fname) = e.path().file_name() {
321                    if fname.to_string_lossy() == name {
322                        return Some(e.into_path());
323                    }
324                }
325            }
326        }
327    }
328    None
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn parse_file_header_sections() {
337        let text = "\
338I'll modify two files:
339
340### File: TopBar.vue
341Replace the avatar with a local image.
342Keep the tab navigation intact.
343
344### File: App.vue
345Import FootBar component and add it to the template.";
346
347        let instrs = parse_edit_instructions(text);
348        assert_eq!(instrs.len(), 2);
349        assert_eq!(instrs[0].file, "TopBar.vue");
350        assert!(instrs[0].instruction.contains("avatar"));
351        assert_eq!(instrs[1].file, "App.vue");
352        assert!(instrs[1].instruction.contains("FootBar"));
353    }
354
355    #[test]
356    fn parse_numbered_list() {
357        let text = "\
358Plan:
3591. TopBar.vue: change avatar to local image
3602. App.vue: import and add FootBar
3613. FootBar.vue: redesign with navigation";
362
363        let instrs = parse_edit_instructions(text);
364        assert_eq!(instrs.len(), 3);
365        assert_eq!(instrs[0].file, "TopBar.vue");
366        assert_eq!(instrs[2].file, "FootBar.vue");
367    }
368
369    #[test]
370    fn parse_bold_headers() {
371        let text = "\
372**TopBar.vue**:
373Fix the avatar section
374
375**App.vue**:
376Add FootBar import";
377
378        let instrs = parse_edit_instructions(text);
379        assert_eq!(instrs.len(), 2);
380    }
381
382    #[test]
383    fn parse_no_instructions() {
384        let text = "I looked at the code and everything seems fine.";
385        let instrs = parse_edit_instructions(text);
386        assert_eq!(instrs.len(), 0);
387    }
388
389    #[test]
390    fn parse_single_file_instruction() {
391        let text = "\
392### File: IdeaCenter.vue
393Change the formatDate function to output MM-DD HH:mm format.";
394
395        let instrs = parse_edit_instructions(text);
396        assert_eq!(instrs.len(), 1);
397        assert_eq!(instrs[0].file, "IdeaCenter.vue");
398        assert!(instrs[0].instruction.contains("formatDate"));
399    }
400}