winx_code_agent/tools/
multi_file_edit.rs1use std::collections::HashSet;
17use std::sync::Arc;
18
19use tokio::sync::Mutex;
20use tracing::instrument;
21
22use crate::errors::{Result, WinxError};
23use crate::state::bash_state::BashState;
24use crate::tools::file_write_or_edit::{commit_edit, plan_edit};
25use crate::types::{normalize_thread_id, FileEditEntry, MultiFileEdit};
26
27const MAX_FILES_PER_BATCH: usize = 100;
32
33#[instrument(level = "info", skip(bash_state_arc, multi))]
34pub async fn handle_tool_call(
35 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
36 multi: MultiFileEdit,
37) -> Result<String> {
38 let mut bash_state_guard = bash_state_arc.lock().await;
39
40 {
42 let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
43 let thread_id = normalize_thread_id(&multi.thread_id);
44 if thread_id != bash_state.current_thread_id {
45 return Err(WinxError::ThreadIdMismatch(thread_id));
46 }
47 }
48 if multi.files.len() < 2 {
49 return Err(WinxError::ArgumentParseError(
50 "MultiFileEdit needs at least 2 files; use FileWriteOrEdit for a single file."
51 .to_string(),
52 ));
53 }
54 if multi.files.len() > MAX_FILES_PER_BATCH {
55 return Err(WinxError::ArgumentParseError(format!(
56 "MultiFileEdit is limited to {MAX_FILES_PER_BATCH} files per batch (got {}); split the \
57 change into smaller batches.",
58 multi.files.len()
59 )));
60 }
61
62 let mut state = bash_state_guard.take().ok_or(WinxError::BashStateNotInitialized)?;
68 let recovery_state = state.clone();
69 let files = multi.files;
70 let joined = tokio::task::spawn_blocking(move || {
71 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
72 apply_batch(&mut state, &files)
73 }))
74 .unwrap_or_else(|_| {
75 Err(WinxError::CommandExecutionError(
76 "MultiFileEdit panicked on the blocking worker".to_string(),
77 ))
78 });
79 (state, result)
80 })
81 .await;
82
83 match joined {
84 Ok((state, result)) => {
85 *bash_state_guard = Some(state);
86 result
87 }
88 Err(error) => {
89 *bash_state_guard = Some(recovery_state);
90 Err(WinxError::CommandExecutionError(format!(
91 "MultiFileEdit blocking task failed: {error}"
92 )))
93 }
94 }
95}
96
97fn apply_batch(bash_state: &mut BashState, files: &[FileEditEntry]) -> Result<String> {
101 let mut planned = Vec::with_capacity(files.len());
104 for (index, entry) in files.iter().enumerate() {
105 let edit = plan_edit(
106 bash_state,
107 &entry.file_path,
108 entry.percentage_to_change,
109 &entry.text_or_search_replace_blocks,
110 )
111 .map_err(|e| {
112 WinxError::ArgumentParseError(format!(
116 "MultiFileEdit aborted before writing anything - file {} ({}) failed validation: {e}",
117 index + 1,
118 entry.file_path
119 ))
120 })?;
121 planned.push(edit);
122 }
123
124 let mut seen = HashSet::with_capacity(planned.len());
129 for edit in &planned {
130 if !seen.insert(edit.target()) {
131 return Err(WinxError::ArgumentParseError(format!(
132 "MultiFileEdit targets '{}' more than once; edits to the same file don't compose - \
133 combine them into a single entry.",
134 edit.target()
135 )));
136 }
137 }
138
139 let total = planned.len();
143 let mut summaries = Vec::with_capacity(total);
144 for (committed, edit) in planned.into_iter().enumerate() {
145 let target = edit.target().to_string();
146 match commit_edit(bash_state, edit) {
147 Ok(summary) => summaries.push(format!("[{target}]\n{summary}")),
148 Err(e) => {
149 return Err(WinxError::CommandExecutionError(format!(
150 "MultiFileEdit: committed {committed} of {total} files, then failed writing \
151 {target}: {e}\nThe {committed} already-written file(s) were NOT rolled back. \
152 Re-read the affected files and retry the rest."
153 )));
154 }
155 }
156 }
157
158 Ok(format!("MultiFileEdit applied all {total} edits:\n\n{}", summaries.join("\n\n")))
159}