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. 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
93/// Build the `edit` tool from a context. Requires a mutation-capable env.
94/// Mirrors TS `createEditTool`.
95pub 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    /// Fold legacy `oldText`/`newText` and parse a JSON-string `edits`. Mirrors
116    /// `prepareEditArguments`.
117    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        // Validate edits non-empty.
131        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                    // FileInfo: must be a file or symlink.
168                    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
225/// Mirrors `prepareEditArguments`: if `edits` is a JSON string, parse it into an
226/// array; if legacy `oldText`/`newText` are present, append them to `edits` and
227/// strip them. Returns the (possibly rewritten) args value.
228fn 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    // Parse a JSON-string `edits`.
234    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    // Legacy oldText/newText.
243    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}