1mod 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#[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
134fn 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
194pub(crate) type ScopeCheck<'a> = &'a dyn Fn(&str) -> bool;
197
198struct FileEntry {
200 display: String,
201 target: PathBuf,
202 dir: dir::Dir,
207 name: OsString,
209 original: Option<String>,
210 identity: Option<(u64, u64)>,
211 mode: Option<u32>,
212 content: String,
213 crlf: bool,
216 created_noted: bool,
218}
219
220fn 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
256fn 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
267struct Loaded {
269 content: String,
270 identity: Option<(u64, u64)>,
271 mode: Option<u32>,
272}
273
274#[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
327fn 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 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
544pub(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
559pub(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 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 "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(¶ms).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;