1use std::sync::Arc;
14
15use async_trait::async_trait;
16use futures::future::BoxFuture;
17use futures::FutureExt;
18use rpi_agent::agent_tool::AgentTool;
19use rpi_agent::error::AgentError;
20use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
21use rpi_ai::types::{ConstrainedSamplingConfig, ConstrainedStrictness, Tool};
22use schemars::JsonSchema;
23use serde::Deserialize;
24use tokio_util::sync::CancellationToken;
25
26use crate::env::FileContent;
27use crate::error::{FileError, FileErrorCode};
28use crate::file_mutation_queue::{with_file_mutation_queue, MutatingEnv};
29use crate::path_utils::resolve_tool_path;
30use crate::tools::edit_diff::{
31 apply_edits_to_normalized_content, detect_line_ending, generate_diff_string,
32 generate_unified_patch, normalize_to_lf, restore_line_endings, strip_bom, ApplyResult,
33 ReplaceEdit,
34};
35use crate::tools::tool_context::ExecutionToolContext;
36
37#[derive(Debug, Clone, JsonSchema, Deserialize)]
39pub struct EditInput {
40 pub path: String,
42 pub edits: Vec<ReplaceEditJson>,
44}
45
46#[derive(Debug, Clone, JsonSchema, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct ReplaceEditJson {
50 pub old_text: String,
53 pub new_text: String,
55}
56
57#[derive(Debug, Clone, serde::Serialize)]
59pub struct EditToolDetails {
60 pub diff: String,
61 pub patch: String,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub first_changed_line: Option<usize>,
64}
65
66pub struct EditTool {
68 schema: Tool,
69 env: Arc<dyn MutatingEnv>,
70}
71
72impl EditTool {
73 fn schema() -> Tool {
74 let params = schemars::schema_for!(EditInput);
75 Tool {
76 name: "edit".to_string(),
77 description: "Edit a single file using exact text replacement. Every edits[].oldText \
78 must match a unique, non-overlapping region of the original file. If two \
79 changes affect the same block or nearby lines, merge them into one edit \
80 instead of emitting overlapping edits. Do not include large unchanged \
81 regions just to connect distant changes."
82 .to_string(),
83 parameters: rpi_ai::types::Schema::new(
84 serde_json::to_value(params).unwrap_or_default(),
85 ),
86 constrained_sampling: Some(ConstrainedSamplingConfig::JsonSchema {
87 strict: ConstrainedStrictness::Prefer,
88 }),
89 }
90 }
91}
92
93pub fn create_edit_tool(context: &ExecutionToolContext) -> Arc<dyn AgentTool> {
96 let env = context
97 .mutating_env()
98 .expect("edit tool requires a mutation-capable ExecutionToolContext")
99 .clone();
100 Arc::new(EditTool {
101 schema: EditTool::schema(),
102 env,
103 })
104}
105
106#[async_trait]
107impl AgentTool for EditTool {
108 fn schema(&self) -> &Tool {
109 &self.schema
110 }
111 fn label(&self) -> &str {
112 "edit"
113 }
114
115 fn prepare_arguments(&self, args: serde_json::Value) -> Result<serde_json::Value, AgentError> {
118 Ok(prepare_edit_arguments(args))
119 }
120
121 async fn execute(
122 &self,
123 _tool_call_id: &str,
124 params: serde_json::Value,
125 signal: CancellationToken,
126 _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
127 ) -> Result<AgentToolResult, AgentError> {
128 let input: EditInput = serde_json::from_value(params)
129 .map_err(|e| AgentError::Validation(format!("edit input invalid: {e}")))?;
130 if input.edits.is_empty() {
132 return Err(AgentError::Tool(
133 "Edit tool input is invalid. edits must contain at least one replacement.".into(),
134 ));
135 }
136 let edits: Vec<ReplaceEdit> = input
137 .edits
138 .into_iter()
139 .map(|e| ReplaceEdit {
140 old_text: e.old_text,
141 new_text: e.new_text,
142 })
143 .collect();
144 let path = input.path.clone();
145 let path_for_msg = path.clone();
146 let edits_count = edits.len();
147
148 let abs = resolve_tool_path(self.env.as_env(), &path, Some(&signal))
149 .await
150 .map_err(file_err_to_agent)?;
151 let abs_str = abs.clone();
152
153 let env = self.env.clone();
154 let env_ref = env.clone();
155 let abs_for_closure = abs_str.clone();
156 let apply_result: Result<ApplyResult, FileError> = with_file_mutation_queue(
157 &*env_ref,
158 &abs_str,
159 &signal,
160 move |cancel: &CancellationToken| -> BoxFuture<'_, Result<ApplyResult, FileError>> {
161 let env = env.clone();
162 let edits = edits.clone();
163 let path = path.clone();
164 let abs_str = abs_for_closure.clone();
165 async move {
166 check_abort(cancel)?;
167 let info = env.as_env().file_info(&abs_str, Some(cancel)).await?;
169 if !matches!(
170 info.kind,
171 crate::env::FileKind::File | crate::env::FileKind::Symlink
172 ) {
173 return Err(FileError::new(
174 FileErrorCode::Invalid,
175 format!("Could not edit file: {path}. Path is not a file."),
176 ));
177 }
178 let read = env.as_env().read_text_file(&abs_str, Some(cancel)).await?;
179 check_abort(cancel)?;
180
181 let bom_res = strip_bom(&read);
182 let bom = bom_res.bom;
183 let content = bom_res.text;
184 let original_ending = detect_line_ending(content);
185 let normalized = normalize_to_lf(content);
186 let apply = apply_edits_to_normalized_content(&normalized, &edits, &path)?;
187 check_abort(cancel)?;
188
189 let final_content = format!(
190 "{bom}{}",
191 restore_line_endings(&apply.new_content, original_ending)
192 );
193 env.as_env()
194 .write_file(&abs_str, FileContent::Text(final_content), Some(cancel))
195 .await?;
196 check_abort(cancel)?;
197 Ok(apply)
198 }
199 .boxed()
200 },
201 )
202 .await;
203
204 let apply = apply_result.map_err(file_err_to_agent)?;
205 let diff = generate_diff_string(&apply.base_content, &apply.new_content, 4);
206 let patch = generate_unified_patch(&path_for_msg, &apply.base_content, &apply.new_content);
207
208 let details = serde_json::to_value(EditToolDetails {
209 diff: diff.diff,
210 patch,
211 first_changed_line: diff.first_changed_line,
212 })
213 .unwrap_or(serde_json::Value::Null);
214
215 Ok(AgentToolResult {
216 content: vec![TextContentOrImage::text(format!(
217 "Successfully replaced {edits_count} block(s) in {path_for_msg}."
218 ))],
219 details,
220 ..Default::default()
221 })
222 }
223}
224
225fn prepare_edit_arguments(input: serde_json::Value) -> serde_json::Value {
229 let mut args = match input {
230 serde_json::Value::Object(m) => m,
231 other => return other,
232 };
233 if let Some(edits_val) = args.get("edits").cloned() {
235 if let serde_json::Value::String(s) = edits_val {
236 if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&s)
237 {
238 args.insert("edits".to_string(), serde_json::Value::Array(arr));
239 }
240 }
241 }
242 let has_legacy = matches!(args.get("oldText"), Some(serde_json::Value::String(_)))
244 && matches!(args.get("newText"), Some(serde_json::Value::String(_)));
245 if !has_legacy {
246 return serde_json::Value::Object(args);
247 }
248 let old_text = args
249 .get("oldText")
250 .and_then(|v| v.as_str())
251 .unwrap_or("")
252 .to_string();
253 let new_text = args
254 .get("newText")
255 .and_then(|v| v.as_str())
256 .unwrap_or("")
257 .to_string();
258 let mut edits_arr = match args.get("edits").cloned() {
259 Some(serde_json::Value::Array(a)) => a,
260 _ => Vec::new(),
261 };
262 edits_arr.push(serde_json::json!({ "oldText": old_text, "newText": new_text }));
263 args.remove("oldText");
264 args.remove("newText");
265 args.insert("edits".to_string(), serde_json::Value::Array(edits_arr));
266 serde_json::Value::Object(args)
267}
268
269fn check_abort(cancel: &CancellationToken) -> Result<(), FileError> {
270 if cancel.is_cancelled() {
271 return Err(FileError::new(FileErrorCode::Aborted, "Operation aborted"));
272 }
273 Ok(())
274}
275
276fn file_err_to_agent(e: FileError) -> AgentError {
277 AgentError::Tool(e.to_string())
278}