Skip to main content

agent_engine/tools/multi_edit/
mod.rs

1//! `multi_edit`: anchored edits across several files. Every file is planned
2//! and checked before any is published; publication then rolls back
3//! best-effort, and a failed rollback is reported.
4//!
5//! One call carries a list of edits. Every edit is validated and applied to
6//! in-memory copies first (`plan`); if any edit fails, no file is written.
7//! The changed files are then staged, re-checked and renamed into place
8//! (`commit`). Clock/mtime placeholders are opt-in per call
9//! (`placeholders`).
10//!
11//! Effect class: `NonIdempotent`, unlike `edit`/`write`. An interrupted
12//! insert/append must never be re-run automatically (it would land twice),
13//! and one concurrency key cannot name several files.
14
15mod commit;
16mod dir;
17mod placeholders;
18mod plan;
19
20use super::{resolve_path_in, Tool, ToolContext};
21use crate::{Result, RuntimeError};
22use serde::Deserialize;
23use serde_json::{json, Value};
24use std::ffi::OsString;
25use std::path::{Path, PathBuf};
26
27pub(crate) const MAX_EDITS: usize = 64;
28pub(crate) const MAX_FILES: usize = 16;
29pub(crate) const MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
30pub(crate) const MAX_TEXT_BYTES: usize = 1024 * 1024;
31pub(crate) const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub(crate) enum Op {
36    Replace,
37    InsertBefore,
38    InsertAfter,
39    Append,
40    AppendJsonl,
41}
42
43impl Op {
44    fn as_str(self) -> &'static str {
45        match self {
46            Op::Replace => "replace",
47            Op::InsertBefore => "insert_before",
48            Op::InsertAfter => "insert_after",
49            Op::Append => "append",
50            Op::AppendJsonl => "append_jsonl",
51        }
52    }
53    fn anchored(self) -> bool {
54        matches!(self, Op::Replace | Op::InsertBefore | Op::InsertAfter)
55    }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub(crate) enum AnchorMode {
61    #[default]
62    Exact,
63    LinePrefix,
64    LineRegex,
65}
66
67/// One edit as sent by the model. Unused optional fields may be omitted or
68/// `null`; unknown fields are refused so a typo is never silently ignored.
69#[derive(Debug, Deserialize)]
70#[serde(deny_unknown_fields)]
71struct EditSpec {
72    path: String,
73    op: Op,
74    #[serde(default)]
75    anchor: Option<String>,
76    #[serde(default)]
77    anchor_mode: Option<AnchorMode>,
78    #[serde(default)]
79    occurrence: Option<u64>,
80    #[serde(default)]
81    text: Option<String>,
82    #[serde(default)]
83    value: Option<serde_json::Map<String, Value>>,
84    #[serde(default)]
85    timestamp_field: Option<String>,
86    #[serde(default)]
87    unless_present: Option<String>,
88    #[serde(default)]
89    create: Option<bool>,
90}
91
92struct Request {
93    edits: Vec<EditSpec>,
94    placeholders: bool,
95}
96
97fn parse_request(params: &Value) -> std::result::Result<Request, String> {
98    let obj = params
99        .as_object()
100        .ok_or("parameters must be an object with an `edits` list")?;
101    for key in obj.keys() {
102        if key != "edits" && key != "placeholders" {
103            return Err(format!("unknown parameter `{key}`"));
104        }
105    }
106    let placeholders = match obj.get("placeholders") {
107        None | Some(Value::Null) => false,
108        Some(Value::Bool(b)) => *b,
109        Some(_) => return Err("`placeholders` must be true or false".into()),
110    };
111    let raw = obj
112        .get("edits")
113        .and_then(Value::as_array)
114        .ok_or("`edits` must be a list")?;
115    if raw.is_empty() {
116        return Err("`edits` is empty".into());
117    }
118    if raw.len() > MAX_EDITS {
119        return Err(format!("{} edits; at most {MAX_EDITS} per call", raw.len()));
120    }
121    let mut edits = Vec::with_capacity(raw.len());
122    for (i, value) in raw.iter().enumerate() {
123        let spec: EditSpec =
124            serde_json::from_value(value.clone()).map_err(|e| format!("edits[{i}]: {e}"))?;
125        check_shape(&spec).map_err(|e| format!("edits[{i}] ({}): {e}", spec.path))?;
126        edits.push(spec);
127    }
128    Ok(Request {
129        edits,
130        placeholders,
131    })
132}
133
134/// Field combinations and sizes, before anything is read.
135fn check_shape(spec: &EditSpec) -> std::result::Result<(), String> {
136    if spec.path.trim().is_empty() {
137        return Err("path is empty".into());
138    }
139    let op = spec.op.as_str();
140    if spec.op.anchored() {
141        match spec.anchor.as_deref() {
142            None => return Err(format!("{op} needs an anchor")),
143            Some("") => return Err("anchor is empty".into()),
144            Some(_) => {}
145        }
146        if spec.create == Some(true) {
147            return Err("create is only for append/append_jsonl".into());
148        }
149    } else if spec.anchor.is_some() || spec.anchor_mode.is_some() || spec.occurrence.is_some() {
150        return Err(format!("{op} takes no anchor, anchor_mode or occurrence"));
151    }
152    if spec.occurrence == Some(0) {
153        return Err("occurrence is 1-based".into());
154    }
155    if spec.op == Op::AppendJsonl {
156        if spec.text.is_some() {
157            return Err("append_jsonl takes `value` (a JSON object), not `text`".into());
158        }
159        if spec.value.is_none() {
160            return Err("append_jsonl needs `value` (a JSON object)".into());
161        }
162        if spec.timestamp_field.as_deref() == Some("") {
163            return Err("timestamp_field is empty".into());
164        }
165    } else {
166        if spec.value.is_some() || spec.timestamp_field.is_some() {
167            return Err(format!("{op} takes `text`, not `value`/`timestamp_field`"));
168        }
169        match spec.text.as_deref() {
170            None => return Err(format!("{op} needs `text`")),
171            Some("") if spec.op != Op::Replace => {
172                return Err(format!("{op} with empty text changes nothing"))
173            }
174            _ => {}
175        }
176    }
177    if spec.unless_present.as_deref() == Some("") {
178        return Err("unless_present is empty (it would always match)".into());
179    }
180    let sizes = [
181        spec.anchor.as_ref().map_or(0, String::len),
182        spec.text.as_ref().map_or(0, String::len),
183        spec.unless_present.as_ref().map_or(0, String::len),
184        spec.value
185            .as_ref()
186            .map_or(0, |v| serde_json::to_string(v).map_or(usize::MAX, |s| s.len())),
187    ];
188    if sizes.iter().any(|&n| n > MAX_TEXT_BYTES) {
189        return Err(format!("a field exceeds {MAX_TEXT_BYTES} bytes"));
190    }
191    Ok(())
192}
193
194/// Worker write scopes: refused when the path, as given, as resolved, or as
195/// the real target (symlinks followed), overlaps an active worker's scope.
196pub(crate) type ScopeCheck<'a> = &'a dyn Fn(&str) -> bool;
197
198/// One file touched by the call, with its in-memory content.
199struct FileEntry {
200    display: String,
201    target: PathBuf,
202    /// The verified parent directory of `target`: opened right after the
203    /// scope check by a no-follow walk of the canonical parent path that was
204    /// checked. One handle per target entry; targets that share a directory
205    /// each get their own (they are not deduplicated).
206    dir: dir::Dir,
207    /// The file name of `target` in `dir`.
208    name: OsString,
209    original: Option<String>,
210    identity: Option<(u64, u64)>,
211    mode: Option<u32>,
212    content: String,
213    /// The file's dominant line ending is CRLF (`plan::is_crlf` of the
214    /// original; a new file is LF).
215    crlf: bool,
216    /// The "(created)" note was already shown for this new file.
217    created_noted: bool,
218}
219
220/// Where `raw` really writes: `(resolved, target, exists)`. An existing path
221/// resolves through symlinks to its target; a missing one is the canonical
222/// parent plus the file name.
223fn resolve_target(raw: &str, cwd: Option<&Path>) -> std::result::Result<(PathBuf, PathBuf, bool), String> {
224    let path = resolve_path_in(raw, cwd);
225    match std::fs::symlink_metadata(&path) {
226        Ok(meta) => {
227            let target = std::fs::canonicalize(&path).map_err(|e| {
228                if meta.file_type().is_symlink() {
229                    format!("dangling symlink ({e})")
230                } else {
231                    format!("cannot resolve the path: {e}")
232                }
233            })?;
234            Ok((path, target, true))
235        }
236        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
237            let name = path
238                .file_name()
239                .ok_or("path has no file name")?
240                .to_os_string();
241            let parent = match path.parent() {
242                Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
243                _ => PathBuf::from("."),
244            };
245            let parent = std::fs::canonicalize(&parent)
246                .map_err(|e| format!("parent directory {} is not usable: {e}", parent.display()))?;
247            if !parent.is_dir() {
248                return Err(format!("parent {} is not a directory", parent.display()));
249            }
250            Ok((path, parent.join(name), false))
251        }
252        Err(e) => Err(format!("cannot inspect the path: {e}")),
253    }
254}
255
256/// The verified parent directory of a resolved target, and the target's
257/// file name in it. Opened by a no-follow walk of the canonical parent path
258/// that was scope-checked; the walk captures the directory's identity.
259fn open_parent(target: &Path) -> std::result::Result<(dir::Dir, OsString), String> {
260    let name = target.file_name().ok_or("path has no file name")?.to_os_string();
261    let parent = target.parent().ok_or("path has no parent directory")?;
262    let dir = dir::Dir::open(parent)
263        .map_err(|e| format!("parent directory {} is not usable: {e}", parent.display()))?;
264    Ok((dir, name))
265}
266
267/// An existing file as read during planning.
268struct Loaded {
269    content: String,
270    identity: Option<(u64, u64)>,
271    mode: Option<u32>,
272}
273
274/// Reads `name` through its parent's handle: never by a path that could
275/// lead elsewhere. The open never waits (a FIFO with no writer, a device)
276/// and the type is checked on the descriptor before anything is read.
277///
278/// `budget` is what is left of the call's input limit. Both limits are
279/// checked from the descriptor's size before reading, and at most
280/// limit + 1 bytes are read, so a file that grew in between is caught too.
281#[cfg_attr(not(test), allow(unused_variables))]
282fn load_existing(
283    dir: &dir::Dir,
284    name: &std::ffi::OsStr,
285    budget: usize,
286    hooks: &mut dyn commit::Hooks,
287) -> std::result::Result<Loaded, String> {
288    let over_budget = || format!("the files this call reads would exceed {MAX_TOTAL_BYTES} bytes");
289    use std::io::Read;
290    let (file, meta) = dir.open_regular(name).map_err(|e| match e {
291        dir::OpenError::NotRegular => "not a regular file".to_string(),
292        dir::OpenError::Symlink => "was replaced by a symlink since it was resolved".to_string(),
293        dir::OpenError::Io(e) => format!("cannot open: {e}"),
294    })?;
295    if meta.nlink > 1 {
296        return Err(format!(
297            "has {} hard links; replacing it would split them",
298            meta.nlink
299        ));
300    }
301    if meta.size > MAX_FILE_BYTES as u64 {
302        return Err(format!("larger than {MAX_FILE_BYTES} bytes"));
303    }
304    if meta.size > budget as u64 {
305        return Err(over_budget());
306    }
307    #[cfg(test)]
308    hooks.before_read();
309    let mut bytes = Vec::with_capacity(meta.size as usize);
310    file.take(MAX_FILE_BYTES.min(budget) as u64 + 1)
311        .read_to_end(&mut bytes)
312        .map_err(|e| format!("cannot read: {e}"))?;
313    if bytes.len() > MAX_FILE_BYTES {
314        return Err(format!("larger than {MAX_FILE_BYTES} bytes"));
315    }
316    if bytes.len() > budget {
317        return Err(over_budget());
318    }
319    let text = String::from_utf8(bytes).map_err(|_| "not UTF-8 text".to_string())?;
320    Ok(Loaded {
321        content: text,
322        identity: meta.identity,
323        mode: meta.mode,
324    })
325}
326
327/// Plan and commit one call. Synchronous: run it off the async runtime.
328fn run(
329    req: Request,
330    now: chrono::DateTime<chrono::Local>,
331    cwd: Option<&Path>,
332    scope_refuses: ScopeCheck<'_>,
333    hooks: &mut dyn commit::Hooks,
334) -> std::result::Result<String, String> {
335    let mut expander = placeholders::Expander::new(now);
336    let uses_clock = req.placeholders
337        || req.edits.iter().any(|e| e.timestamp_field.is_some());
338    let mut files: Vec<FileEntry> = Vec::new();
339    let mut index_of: std::collections::HashMap<PathBuf, usize> = Default::default();
340    let mut lines: Vec<String> = Vec::with_capacity(req.edits.len());
341    let (mut applied, mut skipped) = (0usize, 0usize);
342    // Bytes of existing files read so far (the call's input limit).
343    let mut read_bytes = 0usize;
344
345    for (i, spec) in req.edits.iter().enumerate() {
346        let op = spec.op.as_str();
347        let at = |e: String| format!("edits[{i}] ({}, {op}): {e}; no file was changed", spec.path);
348        let (resolved, target, exists) = resolve_target(&spec.path, cwd).map_err(at)?;
349        for candidate in [
350            spec.path.clone(),
351            resolved.to_string_lossy().into_owned(),
352            target.to_string_lossy().into_owned(),
353        ] {
354            if scope_refuses(&candidate) {
355                return Err(at(format!(
356                    "{candidate} overlaps an active worker's write scope"
357                )));
358            }
359        }
360        let slot = match index_of.get(&target) {
361            Some(&slot) => slot,
362            None => {
363                if files.len() == MAX_FILES {
364                    return Err(at(format!("more than {MAX_FILES} files in one call")));
365                }
366                let (dir, name) = open_parent(&target).map_err(at)?;
367                let entry = if exists {
368                    let Loaded {
369                        content,
370                        identity,
371                        mode,
372                    } = load_existing(&dir, &name, MAX_TOTAL_BYTES - read_bytes, hooks)
373                        .map_err(at)?;
374                    read_bytes += content.len();
375                    FileEntry {
376                        crlf: plan::is_crlf(&content),
377                        display: spec.path.clone(),
378                        target: target.clone(),
379                        dir,
380                        name,
381                        original: Some(content.clone()),
382                        identity,
383                        mode,
384                        content,
385                        created_noted: false,
386                    }
387                } else {
388                    if !(matches!(spec.op, Op::Append | Op::AppendJsonl)
389                        && spec.create == Some(true))
390                    {
391                        return Err(at(
392                            "file does not exist (only append/append_jsonl with create: true may create one)".into(),
393                        ));
394                    }
395                    FileEntry {
396                        display: spec.path.clone(),
397                        target: target.clone(),
398                        dir,
399                        name,
400                        original: None,
401                        identity: None,
402                        mode: None,
403                        crlf: false,
404                        content: String::new(),
405                        created_noted: false,
406                    }
407                };
408                files.push(entry);
409                index_of.insert(target.clone(), files.len() - 1);
410                files.len() - 1
411            }
412        };
413
414        let expand = |expander: &mut placeholders::Expander, s: &str| {
415            if req.placeholders {
416                expander.expand(s)
417            } else {
418                Ok(s.to_string())
419            }
420        };
421        let guard = match &spec.unless_present {
422            Some(g) => Some(expand(&mut expander, g).map_err(at)?),
423            None => None,
424        };
425        let text = if spec.op == Op::AppendJsonl {
426            let mut row = Value::Object(spec.value.clone().unwrap_or_default());
427            if req.placeholders {
428                expander.expand_value(&mut row).map_err(at)?;
429            }
430            if let Some(field) = &spec.timestamp_field {
431                let map = row.as_object_mut().expect("value is an object");
432                if map.contains_key(field) {
433                    return Err(at(format!("timestamp_field `{field}` is already in value")));
434                }
435                map.insert(field.clone(), Value::String(expander.now_iso()));
436            }
437            serde_json::to_string(&row).map_err(|e| at(e.to_string()))?
438        } else {
439            expand(&mut expander, spec.text.as_deref().unwrap_or_default()).map_err(at)?
440        };
441        if text.len() > MAX_TEXT_BYTES {
442            return Err(at(format!("expanded text exceeds {MAX_TEXT_BYTES} bytes")));
443        }
444
445        let entry = &mut files[slot];
446        let outcome = plan::apply(
447            &mut entry.content,
448            spec.op,
449            spec.anchor_mode.unwrap_or_default(),
450            spec.anchor.as_deref(),
451            spec.occurrence,
452            &text,
453            guard.as_deref(),
454            entry.crlf,
455        )
456        .map_err(at)?;
457        if entry.content.len() > MAX_FILE_BYTES {
458            return Err(at(format!("the file would exceed {MAX_FILE_BYTES} bytes")));
459        }
460        lines.push(match outcome {
461            plan::Applied::Skipped { line } => {
462                skipped += 1;
463                format!("[{i}] {} {op} skipped: unless_present found at L{line}", spec.path)
464            }
465            plan::Applied::Edit {
466                first_line,
467                last_line,
468                removed,
469                added,
470                note,
471            } => {
472                applied += 1;
473                let span = if first_line == last_line {
474                    format!("L{first_line}")
475                } else {
476                    format!("L{first_line}-{last_line}")
477                };
478                let bytes = if spec.op == Op::Replace {
479                    format!("-{removed} +{added} B")
480                } else {
481                    format!("+{added} B")
482                };
483                let mut line = format!("[{i}] {} {op} {span} {bytes}", spec.path);
484                if entry.original.is_none() && !entry.created_noted {
485                    entry.created_noted = true;
486                    line.push_str(" (created)");
487                }
488                if let Some(note) = note {
489                    line.push_str(&format!(" ({note})"));
490                }
491                line
492            }
493        });
494    }
495
496    let total: usize = files.iter().map(|f| f.content.len()).sum();
497    if total > MAX_TOTAL_BYTES {
498        return Err(format!(
499            "the call would write {total} bytes; at most {MAX_TOTAL_BYTES}; no file was changed"
500        ));
501    }
502    let pending: Vec<commit::Pending> = files
503        .into_iter()
504        .filter(|f| f.original.as_deref() != Some(f.content.as_str()))
505        .map(|f| commit::Pending {
506            display: f.display,
507            target: f.target,
508            dir: f.dir,
509            name: f.name,
510            original: f.original.map(String::into_bytes),
511            identity: f.identity,
512            mode: f.mode,
513            new: f.content.into_bytes(),
514        })
515        .collect();
516    let notes = if pending.is_empty() {
517        Vec::new()
518    } else {
519        commit::commit(&pending, hooks)?
520    };
521
522    let mut out = format!(
523        "multi_edit: {} file(s) written, {applied} edit(s) applied, {skipped} skipped",
524        pending.len()
525    );
526    if uses_clock {
527        out.push_str(&format!("; clock {}", expander.now_iso()));
528    }
529    out.push_str(" (line numbers are as of each edit)\n");
530    out.push_str(&lines.join("\n"));
531    if !pending.is_empty() {
532        let written: Vec<String> = pending
533            .iter()
534            .map(|p| p.target.display().to_string())
535            .collect();
536        out.push_str(&format!("\nwritten: {}", written.join(", ")));
537    }
538    for note in notes {
539        out.push_str(&format!("\nwarning: {note}"));
540    }
541    Ok(out)
542}
543
544/// The distinct paths a `multi_edit` call names, in first-seen order, as
545/// given (for compaction's file list and status labels). Tolerates any
546/// input shape: anything that is not `edits[].path` strings is ignored.
547pub(crate) fn paths(input: &Value) -> Vec<&str> {
548    let mut out: Vec<&str> = Vec::new();
549    for edit in input["edits"].as_array().into_iter().flatten() {
550        if let Some(path) = edit["path"].as_str() {
551            if !out.contains(&path) {
552                out.push(path);
553            }
554        }
555    }
556    out
557}
558
559/// One-line status: `editing a.md` or `editing a.md +2 files`.
560pub(crate) fn label(input: &Value) -> String {
561    let paths = paths(input);
562    let short = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
563    match paths.as_slice() {
564        [] => "editing ?".to_string(),
565        [one] => format!("editing {}", short(one)),
566        [first, rest @ ..] => format!("editing {} +{} files", short(first), rest.len()),
567    }
568}
569
570pub struct MultiEditTool;
571
572#[async_trait::async_trait]
573impl Tool for MultiEditTool {
574    fn effect(&self) -> crate::tools::catalog::ToolEffect {
575        // Not IdempotentWrite: see the module docs.
576        crate::tools::catalog::ToolEffect::NonIdempotent
577    }
578
579    fn concurrency_key(&self, _validated_input: &Value) -> Option<super::ConcurrencyKey> {
580        Some(super::ConcurrencyKey::Serialize)
581    }
582
583    fn origin(&self) -> crate::tools::ToolOrigin {
584        crate::tools::ToolOrigin::Builtin
585    }
586
587    fn name(&self) -> &str {
588        "multi_edit"
589    }
590
591    fn description(&self) -> &str {
592        // Every word here is sent with every request: keep it to what a
593        // caller needs (docs/multi-edit.md has the rest). The limits are
594        // checked against the constants by a test.
595        "Anchored edits across files in one call, all or nothing: if any edit fails, nothing is written. A file's edits apply in order. Each anchor must match exactly once unless occurrence is set. insert_*: text ending in a newline is inserted as lines before/after the anchor's line, other text at the anchor. append adds a missing final newline. In CRLF files, newlines in anchor/text are CRLF. Max 64 edits, 16 files, 16 MiB per file (64 MiB total), 1 MiB per field. UTF-8."
596    }
597
598    fn parameters(&self) -> Value {
599        json!({
600            "type": "object",
601            "properties": {
602                "edits": {
603                    "type": "array",
604                    "maxItems": MAX_EDITS,
605                    "items": {
606                        "type": "object",
607                        "properties": {
608                            "path": {"type": "string"},
609                            "op": {"type": "string", "enum": ["replace", "insert_before", "insert_after", "append", "append_jsonl"]},
610                            "anchor": {"type": "string"},
611                            "anchor_mode": {"type": "string", "enum": ["exact", "line_prefix", "line_regex"], "description": "exact (default): substring; line_prefix: line starts with it; line_regex: per-line regex, a capture group narrows the span"},
612                            "occurrence": {"type": "integer", "minimum": 1, "description": "Use the Nth match"},
613                            "text": {"type": "string"},
614                            "value": {"type": "object", "description": "append_jsonl row"},
615                            "timestamp_field": {"type": "string", "description": "append_jsonl: field set to now"},
616                            "unless_present": {"type": "string", "description": "Skip if the file contains it"},
617                            "create": {"type": "boolean", "description": "append*: create if missing"}
618                        },
619                        "required": ["path", "op"]
620                    }
621                },
622                "placeholders": {"type": "boolean", "description": "Expand {{now_iso}} {{now:FMT}} {{mtime_iso:/path}} {{mtime:FMT:/path}} in text/value/unless_present"}
623            },
624            "required": ["edits"]
625        })
626    }
627
628    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
629        let req = parse_request(&params).map_err(RuntimeError::Tool)?;
630        let now = chrono::Local::now();
631        let cwd = ctx.capabilities.cwd.clone();
632        let orchestration = ctx.capabilities.orchestration.clone();
633        tokio::task::spawn_blocking(move || {
634            let refuses = |path: &str| {
635                orchestration.as_ref().is_some_and(|o| {
636                    matches!(
637                        o.check_foreground_write(path),
638                        agent_core::orchestration::ScopeDecision::ReconciliationRequired { .. }
639                    )
640                })
641            };
642            run(req, now, cwd.as_deref(), &refuses, &mut commit::RealHooks)
643        })
644        .await
645        .map_err(|e| RuntimeError::Tool(format!("multi_edit task failed: {e}")))?
646        .map_err(RuntimeError::Tool)
647    }
648}
649
650#[cfg(test)]
651mod tests;