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. Before editing, use read to inspect the current target region and copy oldText from that output, preserving whitespace and excluding display line numbers or truncation markers. If matching fails, read the target region again and rebuild the edit from the current content before retrying. Every edits[].oldText must match a unique, non-overlapping region of the original file. Each edit is matched against the same original file snapshot, not incrementally after earlier edits. Do not include overlapping or nested edits. If two changes affect the same block or nearby lines, merge them into one edit. Choose enough surrounding lines from the read output to make each oldText occur exactly once in the entire file; do not use a repeated statement or short identifier alone. If multiple matches are reported, use their line numbers and context to expand oldText for the intended location before retrying. Use camelCase oldText/newText fields.".to_string(),
78 parameters: rpi_ai::types::Schema::new(
79 serde_json::to_value(params).unwrap_or_default(),
80 ),
81 constrained_sampling: Some(ConstrainedSamplingConfig::JsonSchema {
82 strict: ConstrainedStrictness::Prefer,
83 }),
84 }
85 }
86}
87
88pub fn create_edit_tool(context: &ExecutionToolContext) -> Arc<dyn AgentTool> {
91 let env = context
92 .mutating_env()
93 .expect("edit tool requires a mutation-capable ExecutionToolContext")
94 .clone();
95 Arc::new(EditTool {
96 schema: EditTool::schema(),
97 env,
98 })
99}
100
101#[async_trait]
102impl AgentTool for EditTool {
103 fn schema(&self) -> &Tool {
104 &self.schema
105 }
106 fn label(&self) -> &str {
107 "edit"
108 }
109
110 fn prepare_arguments(&self, args: serde_json::Value) -> Result<serde_json::Value, AgentError> {
113 Ok(prepare_edit_arguments(args))
114 }
115
116 async fn execute(
117 &self,
118 _tool_call_id: &str,
119 params: serde_json::Value,
120 signal: CancellationToken,
121 _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
122 ) -> Result<AgentToolResult, AgentError> {
123 let input: EditInput = serde_json::from_value(params)
124 .map_err(|e| AgentError::Validation(format!("edit input invalid: {e}")))?;
125 if input.edits.is_empty() {
127 return Err(AgentError::Tool(
128 "Edit tool input is invalid. edits must contain at least one replacement.".into(),
129 ));
130 }
131 let edits: Vec<ReplaceEdit> = input
132 .edits
133 .into_iter()
134 .map(|e| ReplaceEdit {
135 old_text: e.old_text,
136 new_text: e.new_text,
137 })
138 .collect();
139 let path = input.path.clone();
140 let path_for_msg = path.clone();
141 let edits_count = edits.len();
142
143 let abs = resolve_tool_path(self.env.as_env(), &path, Some(&signal))
144 .await
145 .map_err(file_err_to_agent)?;
146 let abs_str = abs.clone();
147
148 let env = self.env.clone();
149 let env_ref = env.clone();
150 let abs_for_closure = abs_str.clone();
151 let apply_result: Result<ApplyResult, FileError> = with_file_mutation_queue(
152 &*env_ref,
153 &abs_str,
154 &signal,
155 move |cancel: &CancellationToken| -> BoxFuture<'_, Result<ApplyResult, FileError>> {
156 let env = env.clone();
157 let edits = edits.clone();
158 let path = path.clone();
159 let abs_str = abs_for_closure.clone();
160 async move {
161 check_abort(cancel)?;
162 let info = env.as_env().file_info(&abs_str, Some(cancel)).await?;
164 if !matches!(
165 info.kind,
166 crate::env::FileKind::File | crate::env::FileKind::Symlink
167 ) {
168 return Err(FileError::new(
169 FileErrorCode::Invalid,
170 format!("Could not edit file: {path}. Path is not a file."),
171 ));
172 }
173 let read = env.as_env().read_text_file(&abs_str, Some(cancel)).await?;
174 check_abort(cancel)?;
175
176 let bom_res = strip_bom(&read);
177 let bom = bom_res.bom;
178 let content = bom_res.text;
179 let original_ending = detect_line_ending(content);
180 let normalized = normalize_to_lf(content);
181 let apply = apply_edits_to_normalized_content(&normalized, &edits, &path)?;
182 check_abort(cancel)?;
183
184 let final_content = format!(
185 "{bom}{}",
186 restore_line_endings(&apply.new_content, original_ending)
187 );
188 env.as_env()
189 .write_file(&abs_str, FileContent::Text(final_content), Some(cancel))
190 .await?;
191 check_abort(cancel)?;
192 Ok(apply)
193 }
194 .boxed()
195 },
196 )
197 .await;
198
199 let apply = apply_result.map_err(file_err_to_agent)?;
200 let diff = generate_diff_string(&apply.base_content, &apply.new_content, 4);
201 let patch = generate_unified_patch(&path_for_msg, &apply.base_content, &apply.new_content);
202
203 let details = serde_json::to_value(EditToolDetails {
204 diff: diff.diff,
205 patch,
206 first_changed_line: diff.first_changed_line,
207 })
208 .unwrap_or(serde_json::Value::Null);
209
210 Ok(AgentToolResult {
211 content: vec![TextContentOrImage::text(format!(
212 "Successfully replaced {edits_count} block(s) in {path_for_msg}."
213 ))],
214 details,
215 ..Default::default()
216 })
217 }
218}
219
220fn prepare_edit_arguments(input: serde_json::Value) -> serde_json::Value {
224 let mut args = match input {
225 serde_json::Value::Object(m) => m,
226 other => return other,
227 };
228 if let Some(edits_val) = args.get("edits").cloned() {
231 match edits_val {
232 serde_json::Value::String(s) => {
233 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&s) {
234 if parsed.is_array() {
235 args.insert("edits".to_string(), parsed);
236 } else if is_single_edit_object(&parsed) {
237 args.insert("edits".to_string(), serde_json::Value::Array(vec![parsed]));
238 }
239 }
240 }
241 value if is_single_edit_object(&value) => {
242 args.insert("edits".to_string(), serde_json::Value::Array(vec![value]));
243 }
244 _ => {}
245 }
246 }
247 fn is_single_edit_object(value: &serde_json::Value) -> bool {
248 value
249 .as_object()
250 .map(|object| {
251 matches!(object.get("oldText"), Some(serde_json::Value::String(_)))
252 && matches!(object.get("newText"), Some(serde_json::Value::String(_)))
253 })
254 .unwrap_or(false)
255 }
256
257 let has_legacy = matches!(args.get("oldText"), Some(serde_json::Value::String(_)))
259 && matches!(args.get("newText"), Some(serde_json::Value::String(_)));
260 if !has_legacy {
261 return serde_json::Value::Object(args);
262 }
263 let old_text = args
264 .get("oldText")
265 .and_then(|v| v.as_str())
266 .unwrap_or("")
267 .to_string();
268 let new_text = args
269 .get("newText")
270 .and_then(|v| v.as_str())
271 .unwrap_or("")
272 .to_string();
273 let mut edits_arr = match args.get("edits").cloned() {
274 Some(serde_json::Value::Array(a)) => a,
275 _ => Vec::new(),
276 };
277 edits_arr.push(serde_json::json!({ "oldText": old_text, "newText": new_text }));
278 args.remove("oldText");
279 args.remove("newText");
280 args.insert("edits".to_string(), serde_json::Value::Array(edits_arr));
281 serde_json::Value::Object(args)
282}
283
284fn check_abort(cancel: &CancellationToken) -> Result<(), FileError> {
285 if cancel.is_cancelled() {
286 return Err(FileError::new(FileErrorCode::Aborted, "Operation aborted"));
287 }
288 Ok(())
289}
290
291fn file_err_to_agent(e: FileError) -> AgentError {
292 AgentError::Tool(e.to_string())
293}