Skip to main content

rpi_tools/tools/
edit.rs

1//! Mirrors `packages/agent/src/harness/tools/edit.ts` — the built-in `edit`
2//! `AgentTool`. Performs exact-text replacements (with fuzzy fallback) on a
3//! single file, serialized per canonical path via the file-mutation-queue.
4//!
5//! `prepare_arguments` folds legacy top-level `oldText`/`newText` into the
6//! `edits[]` array and parses a JSON-string `edits`, mirroring
7//! `prepareEditArguments`. The execute path runs:
8//! `fileInfo` → `readTextFile` → `stripBom` → `detectLineEnding` →
9//! `normalizeToLF` → `applyEditsToNormalizedContent` → `restoreLineEndings` →
10//! `writeFile`, with `generateDiffString` + `generateUnifiedPatch` in the
11//! details.
12
13use 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/// Input for the edit tool. Mirrors TS `EditToolInput`.
38#[derive(Debug, Clone, JsonSchema, Deserialize)]
39pub struct EditInput {
40    /// Path to the file to edit (relative or absolute).
41    pub path: String,
42    /// One or more targeted replacements.
43    pub edits: Vec<ReplaceEditJson>,
44}
45
46/// JSON-schema-facing edit shape (camelCase to match the TS wire contract).
47#[derive(Debug, Clone, JsonSchema, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct ReplaceEditJson {
50    /// Exact text for one targeted replacement. Must be unique in the original
51    /// file and must not overlap with any other edit's `oldText` in the same call.
52    pub old_text: String,
53    /// Replacement text for this targeted edit.
54    pub new_text: String,
55}
56
57/// Structured details for the edit tool. Mirrors TS `EditToolDetails`.
58#[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
66/// The built-in `edit` tool.
67pub 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
88/// Build the `edit` tool from a context. Requires a mutation-capable env.
89/// Mirrors TS `createEditTool`.
90pub 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    /// Fold legacy `oldText`/`newText` and parse a JSON-string `edits`. Mirrors
111    /// `prepareEditArguments`.
112    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        // Validate edits non-empty.
126        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                    // FileInfo: must be a file or symlink.
163                    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
220/// Mirrors `prepareEditArguments`: if `edits` is a JSON string, parse it into an
221/// array; if legacy `oldText`/`newText` are present, append them to `edits` and
222/// strip them. Returns the (possibly rewritten) args value.
223fn 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    // Parse a JSON-string `edits`. Native Pi accepts either an array or one
229    // edit object here; normalize both forms before serde validation.
230    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    // Legacy oldText/newText.
258    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}