atomcode_core/agent/
execute.rs1use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use super::AgentEvent;
12use crate::turn::event::{TurnEvent, TurnResult};
13
14#[derive(Debug, Clone)]
16pub struct EditInstruction {
17 pub file: String,
19 pub instruction: String,
21}
22
23pub fn parse_edit_instructions(text: &str) -> Vec<EditInstruction> {
35 let mut instructions = Vec::new();
36
37 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 let file_from_header = extract_file_from_header(trimmed);
46 if let Some(file) = file_from_header {
47 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 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 if instructions.is_empty() {
81 for line in text.lines() {
82 let trimmed = line.trim();
83 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 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
123fn extract_file_from_header(line: &str) -> Option<String> {
125 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 let bare = rest.trim_matches('*').trim();
139 if is_source_file(bare) {
140 return Some(bare.to_string());
141 }
142 }
143
144 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
180pub 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 let file_path = if std::path::Path::new(&instr.file).is_absolute() {
195 instr.file.clone()
196 } else {
197 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 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 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 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
302fn find_file(dir: &std::path::Path, name: &str) -> Option<std::path::PathBuf> {
304 let direct = dir.join(name);
306 if direct.exists() {
307 return Some(direct);
308 }
309
310 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}