Skip to main content

supercode/tools/
builtins.rs

1//! Built-in tools: file read/write/edit, directory listing, glob, content
2//! search, and shell execution.
3
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::de::DeserializeOwned;
9use serde::Deserialize;
10use serde_json::{json, Value};
11
12use crate::error::{Error, Result};
13use crate::tools::{Tool, ToolContext};
14
15const MAX_READ_BYTES: usize = 400_000;
16const DEFAULT_BASH_TIMEOUT_MS: u64 = 120_000;
17
18fn parse_args<T: DeserializeOwned>(tool: &str, args: Value) -> Result<T> {
19    serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
20        tool: tool.to_string(),
21        message: e.to_string(),
22    })
23}
24
25fn rel(ctx: &ToolContext, p: &Path) -> String {
26    p.strip_prefix(&ctx.cwd)
27        .unwrap_or(p)
28        .to_string_lossy()
29        .into_owned()
30}
31
32// ---- read -----------------------------------------------------------------
33
34/// Read a UTF-8 text file.
35pub struct ReadFileTool;
36
37#[derive(Deserialize)]
38struct ReadArgs {
39    path: String,
40    #[serde(default)]
41    offset: Option<usize>,
42    #[serde(default)]
43    limit: Option<usize>,
44}
45
46#[async_trait]
47impl Tool for ReadFileTool {
48    fn name(&self) -> &str {
49        "read_file"
50    }
51    fn description(&self) -> &str {
52        "Read the contents of a UTF-8 text file. Optionally pass `offset` (1-based start line) and `limit` (number of lines) to read a slice of a large file."
53    }
54    fn parameters(&self) -> Value {
55        json!({
56            "type": "object",
57            "properties": {
58                "path": {"type": "string", "description": "File path, absolute or relative to the working directory."},
59                "offset": {"type": "integer", "description": "1-based line to start at."},
60                "limit": {"type": "integer", "description": "Maximum number of lines to return."}
61            },
62            "required": ["path"],
63            "additionalProperties": false
64        })
65    }
66    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
67        let a: ReadArgs = parse_args(self.name(), args)?;
68        let path = ctx.resolve(&a.path);
69        let bytes = tokio::fs::read(&path)
70            .await
71            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
72        if bytes.len() > MAX_READ_BYTES && a.offset.is_none() && a.limit.is_none() {
73            return Err(Error::tool(
74                self.name(),
75                format!(
76                    "file is {} bytes (> {MAX_READ_BYTES}); pass offset/limit to read a slice",
77                    bytes.len()
78                ),
79            ));
80        }
81        let text = String::from_utf8_lossy(&bytes);
82        if a.offset.is_none() && a.limit.is_none() {
83            return Ok(text.into_owned());
84        }
85        let start = a.offset.unwrap_or(1).saturating_sub(1);
86        let limit = a.limit.unwrap_or(usize::MAX);
87        let sliced: Vec<&str> = text.lines().skip(start).take(limit).collect();
88        Ok(sliced.join("\n"))
89    }
90}
91
92// ---- write ----------------------------------------------------------------
93
94/// Create or overwrite a file.
95pub struct WriteFileTool;
96
97#[derive(Deserialize)]
98struct WriteArgs {
99    path: String,
100    content: String,
101}
102
103#[async_trait]
104impl Tool for WriteFileTool {
105    fn name(&self) -> &str {
106        "write_file"
107    }
108    fn description(&self) -> &str {
109        "Create or overwrite a file with the given contents. Parent directories are created as needed."
110    }
111    fn parameters(&self) -> Value {
112        json!({
113            "type": "object",
114            "properties": {
115                "path": {"type": "string", "description": "File path to write."},
116                "content": {"type": "string", "description": "Full file contents."}
117            },
118            "required": ["path", "content"],
119            "additionalProperties": false
120        })
121    }
122    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
123        let a: WriteArgs = parse_args(self.name(), args)?;
124        let path = ctx.resolve(&a.path);
125        ctx.check_write(&path)?;
126        if let Some(parent) = path.parent() {
127            tokio::fs::create_dir_all(parent).await.ok();
128        }
129        tokio::fs::write(&path, a.content.as_bytes())
130            .await
131            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
132        Ok(format!(
133            "Wrote {} bytes to {}",
134            a.content.len(),
135            rel(ctx, &path)
136        ))
137    }
138}
139
140// ---- edit -----------------------------------------------------------------
141
142/// Replace an exact substring in a file.
143pub struct EditFileTool;
144
145#[derive(Deserialize)]
146struct EditArgs {
147    path: String,
148    old_string: String,
149    new_string: String,
150    #[serde(default)]
151    replace_all: bool,
152}
153
154#[async_trait]
155impl Tool for EditFileTool {
156    fn name(&self) -> &str {
157        "edit_file"
158    }
159    fn description(&self) -> &str {
160        "Replace an exact substring in a file. By default `old_string` must occur exactly once; set `replace_all` to replace every occurrence."
161    }
162    fn parameters(&self) -> Value {
163        json!({
164            "type": "object",
165            "properties": {
166                "path": {"type": "string"},
167                "old_string": {"type": "string", "description": "Exact text to replace."},
168                "new_string": {"type": "string", "description": "Replacement text."},
169                "replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring uniqueness."}
170            },
171            "required": ["path", "old_string", "new_string"],
172            "additionalProperties": false
173        })
174    }
175    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
176        let a: EditArgs = parse_args(self.name(), args)?;
177        if a.old_string.is_empty() {
178            // An empty needle matches at every char boundary; with replace_all
179            // that would interleave new_string through the whole file.
180            return Err(Error::tool(self.name(), "old_string must not be empty"));
181        }
182        let path = ctx.resolve(&a.path);
183        ctx.check_write(&path)?;
184        let original = tokio::fs::read_to_string(&path)
185            .await
186            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
187        let count = original.matches(&a.old_string).count();
188        if count == 0 {
189            return Err(Error::tool(self.name(), "old_string not found in file"));
190        }
191        if count > 1 && !a.replace_all {
192            return Err(Error::tool(
193                self.name(),
194                format!("old_string occurs {count} times; pass replace_all or add more context"),
195            ));
196        }
197        let updated = if a.replace_all {
198            original.replace(&a.old_string, &a.new_string)
199        } else {
200            original.replacen(&a.old_string, &a.new_string, 1)
201        };
202        tokio::fs::write(&path, updated.as_bytes())
203            .await
204            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
205        Ok(format!(
206            "Replaced {} occurrence(s) in {}",
207            if a.replace_all { count } else { 1 },
208            rel(ctx, &path)
209        ))
210    }
211}
212
213// ---- list -----------------------------------------------------------------
214
215/// List directory entries.
216pub struct ListDirTool;
217
218#[derive(Deserialize)]
219struct ListArgs {
220    #[serde(default)]
221    path: Option<String>,
222}
223
224#[async_trait]
225impl Tool for ListDirTool {
226    fn name(&self) -> &str {
227        "list_dir"
228    }
229    fn description(&self) -> &str {
230        "List the entries of a directory (defaults to the working directory). Directories are suffixed with `/`."
231    }
232    fn parameters(&self) -> Value {
233        json!({
234            "type": "object",
235            "properties": {
236                "path": {"type": "string", "description": "Directory to list. Defaults to the working directory."}
237            },
238            "additionalProperties": false
239        })
240    }
241    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
242        let a: ListArgs = parse_args(self.name(), args)?;
243        let dir = match a.path {
244            Some(p) => ctx.resolve(&p),
245            None => ctx.cwd.clone(),
246        };
247        let mut rd = tokio::fs::read_dir(&dir)
248            .await
249            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", dir.display())))?;
250        let mut entries = Vec::new();
251        while let Some(e) = rd
252            .next_entry()
253            .await
254            .map_err(|e| Error::tool(self.name(), e.to_string()))?
255        {
256            let name = e.file_name().to_string_lossy().into_owned();
257            let is_dir = e.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
258            entries.push(if is_dir { format!("{name}/") } else { name });
259        }
260        entries.sort();
261        if entries.is_empty() {
262            Ok("(empty directory)".to_string())
263        } else {
264            Ok(entries.join("\n"))
265        }
266    }
267}
268
269// ---- glob -----------------------------------------------------------------
270
271/// Match files by glob pattern.
272pub struct GlobTool;
273
274#[derive(Deserialize)]
275struct GlobArgs {
276    pattern: String,
277}
278
279#[async_trait]
280impl Tool for GlobTool {
281    fn name(&self) -> &str {
282        "glob"
283    }
284    fn description(&self) -> &str {
285        "Find files matching a glob pattern (e.g. `src/**/*.rs`), relative to the working directory."
286    }
287    fn parameters(&self) -> Value {
288        json!({
289            "type": "object",
290            "properties": {
291                "pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.rs"}
292            },
293            "required": ["pattern"],
294            "additionalProperties": false
295        })
296    }
297    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
298        let a: GlobArgs = parse_args(self.name(), args)?;
299        let cwd = ctx.cwd.clone();
300        let full = if PathBuf::from(&a.pattern).is_absolute() {
301            a.pattern.clone()
302        } else {
303            cwd.join(&a.pattern).to_string_lossy().into_owned()
304        };
305        let cwd2 = cwd.clone();
306        let matches = tokio::task::spawn_blocking(move || {
307            let mut out = Vec::new();
308            if let Ok(paths) = glob::glob(&full) {
309                for p in paths.flatten() {
310                    let display = p
311                        .strip_prefix(&cwd2)
312                        .unwrap_or(&p)
313                        .to_string_lossy()
314                        .into_owned();
315                    out.push(display);
316                }
317            }
318            out
319        })
320        .await
321        .map_err(|e| Error::tool("glob", e.to_string()))?;
322        if matches.is_empty() {
323            Ok("(no matches)".to_string())
324        } else {
325            Ok(matches.join("\n"))
326        }
327    }
328}
329
330// ---- search ---------------------------------------------------------------
331
332/// Regex search file contents (respecting .gitignore).
333pub struct SearchTool;
334
335#[derive(Deserialize)]
336struct SearchArgs {
337    pattern: String,
338    #[serde(default)]
339    path: Option<String>,
340    #[serde(default)]
341    max_results: Option<usize>,
342}
343
344#[async_trait]
345impl Tool for SearchTool {
346    fn name(&self) -> &str {
347        "search"
348    }
349    fn description(&self) -> &str {
350        "Search file contents with a regular expression, respecting .gitignore. Returns `path:line: text` matches."
351    }
352    fn parameters(&self) -> Value {
353        json!({
354            "type": "object",
355            "properties": {
356                "pattern": {"type": "string", "description": "Regular expression to search for."},
357                "path": {"type": "string", "description": "Directory or file to search. Defaults to the working directory."},
358                "max_results": {"type": "integer", "description": "Cap on the number of matches (default 200)."}
359            },
360            "required": ["pattern"],
361            "additionalProperties": false
362        })
363    }
364    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
365        let a: SearchArgs = parse_args(self.name(), args)?;
366        let re = regex::Regex::new(&a.pattern)
367            .map_err(|e| Error::tool(self.name(), format!("invalid regex: {e}")))?;
368        let root = match a.path {
369            Some(p) => ctx.resolve(&p),
370            None => ctx.cwd.clone(),
371        };
372        let cwd = ctx.cwd.clone();
373        let cap = a.max_results.unwrap_or(200);
374        let results = tokio::task::spawn_blocking(move || {
375            let mut out: Vec<String> = Vec::new();
376            let walker = ignore::WalkBuilder::new(&root).build();
377            'outer: for entry in walker.flatten() {
378                if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
379                    continue;
380                }
381                let path = entry.path();
382                let Ok(content) = std::fs::read_to_string(path) else {
383                    continue; // skip binary / unreadable
384                };
385                for (i, line) in content.lines().enumerate() {
386                    if re.is_match(line) {
387                        let rel = path.strip_prefix(&cwd).unwrap_or(path);
388                        out.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
389                        if out.len() >= cap {
390                            break 'outer;
391                        }
392                    }
393                }
394            }
395            out
396        })
397        .await
398        .map_err(|e| Error::tool("search", e.to_string()))?;
399        if results.is_empty() {
400            Ok("(no matches)".to_string())
401        } else {
402            Ok(results.join("\n"))
403        }
404    }
405}
406
407// ---- bash -----------------------------------------------------------------
408
409/// Run a shell command via `sh -c`.
410pub struct BashTool {
411    default_timeout_ms: u64,
412}
413
414impl Default for BashTool {
415    fn default() -> Self {
416        BashTool {
417            default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
418        }
419    }
420}
421
422#[derive(Deserialize)]
423struct BashArgs {
424    command: String,
425    #[serde(default)]
426    timeout_ms: Option<u64>,
427}
428
429#[async_trait]
430impl Tool for BashTool {
431    fn name(&self) -> &str {
432        "bash"
433    }
434    fn description(&self) -> &str {
435        "Execute a shell command via `sh -c` in the working directory and return its combined stdout/stderr and exit code."
436    }
437    fn parameters(&self) -> Value {
438        json!({
439            "type": "object",
440            "properties": {
441                "command": {"type": "string", "description": "Shell command to run."},
442                "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
443            },
444            "required": ["command"],
445            "additionalProperties": false
446        })
447    }
448    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
449        let a: BashArgs = parse_args(self.name(), args)?;
450        let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
451
452        let mut cmd = build_sandboxed_sh(&a.command, ctx);
453        cmd.current_dir(&ctx.cwd).stdin(std::process::Stdio::null());
454
455        let fut = cmd.output();
456        let output = match tokio::time::timeout(timeout, fut).await {
457            Ok(Ok(o)) => o,
458            Ok(Err(e)) => return Err(Error::tool(self.name(), e.to_string())),
459            Err(_) => {
460                return Err(Error::tool(
461                    self.name(),
462                    format!("command timed out after {timeout:?}"),
463                ))
464            }
465        };
466
467        let mut buf = String::new();
468        let stdout = String::from_utf8_lossy(&output.stdout);
469        let stderr = String::from_utf8_lossy(&output.stderr);
470        if !stdout.is_empty() {
471            buf.push_str(&stdout);
472        }
473        if !stderr.is_empty() {
474            if !buf.is_empty() && !buf.ends_with('\n') {
475                buf.push('\n');
476            }
477            buf.push_str(&stderr);
478        }
479        let code = output.status.code().unwrap_or(-1);
480        if buf.is_empty() {
481            buf.push_str("(no output)");
482        }
483        Ok(format!("exit code: {code}\n{buf}"))
484    }
485}
486
487/// Build the `sh -c <command>` invocation, wrapped in an OS process sandbox
488/// when the policy requires it and the platform supports it.
489///
490/// On macOS this uses `sandbox-exec` (seatbelt): `ReadOnly` denies all file
491/// writes; `WorkspaceWrite` denies writes outside the working directory. This
492/// gives real subprocess isolation (a `bash` command cannot escape the policy),
493/// not just the file-tool confinement. On other platforms (no portable
494/// primitive wired up yet) the command runs unsandboxed — callers needing
495/// hard isolation there should run supercode inside a container.
496fn build_sandboxed_sh(command: &str, ctx: &ToolContext) -> tokio::process::Command {
497    #[cfg(target_os = "macos")]
498    {
499        use crate::tools::SandboxPolicy;
500        if ctx.sandbox != SandboxPolicy::DangerFullAccess {
501            if let Some(profile) = seatbelt_profile(ctx) {
502                let mut cmd = tokio::process::Command::new("sandbox-exec");
503                cmd.arg("-p").arg(profile).arg("sh").arg("-c").arg(command);
504                return cmd;
505            }
506        }
507    }
508    let _ = ctx;
509    let mut cmd = tokio::process::Command::new("sh");
510    cmd.arg("-c").arg(command);
511    cmd
512}
513
514/// Like [`build_sandboxed_sh`] but for the *persistent* shell: an interactive
515/// `sh` reading commands from its stdin (no `-c`). Without this the `shell`
516/// tool would be an unsandboxed escape hatch around the policy that `bash`
517/// honors.
518fn build_sandboxed_interactive_sh(ctx: &ToolContext) -> tokio::process::Command {
519    #[cfg(target_os = "macos")]
520    {
521        use crate::tools::SandboxPolicy;
522        if ctx.sandbox != SandboxPolicy::DangerFullAccess {
523            if let Some(profile) = seatbelt_profile(ctx) {
524                let mut cmd = tokio::process::Command::new("sandbox-exec");
525                cmd.arg("-p").arg(profile).arg("sh");
526                return cmd;
527            }
528        }
529    }
530    let _ = ctx;
531    tokio::process::Command::new("sh")
532}
533
534/// A seatbelt profile string for the current sandbox policy, or `None` for
535/// full access.
536#[cfg(target_os = "macos")]
537fn seatbelt_profile(ctx: &ToolContext) -> Option<String> {
538    use crate::tools::SandboxPolicy;
539    match ctx.sandbox {
540        SandboxPolicy::DangerFullAccess => None,
541        SandboxPolicy::ReadOnly => Some("(version 1)(allow default)(deny file-write*)".to_string()),
542        SandboxPolicy::WorkspaceWrite => {
543            // Allow writes only under the (real) working directory, plus the
544            // usual harmless devices/temp.
545            let real = std::fs::canonicalize(&ctx.cwd).unwrap_or_else(|_| ctx.cwd.clone());
546            let dir = real.to_string_lossy().replace('"', "");
547            Some(format!(
548                "(version 1)(allow default)(deny file-write*)\
549(allow file-write* (subpath \"{dir}\"))\
550(allow file-write* (literal \"/dev/null\") (literal \"/dev/dtracehelper\") (literal \"/dev/tty\"))"
551            ))
552        }
553    }
554}
555
556// ---- apply_patch ----------------------------------------------------------
557
558/// Apply a Codex-style `apply_patch` envelope — Codex's primary edit
559/// mechanism, richer than `edit_file`'s single string replace. Supports
560/// `Add File`, `Delete File`, and `Update File` (with `-`/`+`/context hunks and
561/// an optional `*** Move to:` rename) in one atomic-ish call.
562pub struct ApplyPatchTool;
563
564#[derive(Deserialize)]
565struct ApplyPatchArgs {
566    /// The full `*** Begin Patch … *** End Patch` text.
567    patch: String,
568}
569
570/// One file operation parsed from a patch.
571enum PatchOp {
572    Add {
573        path: String,
574        body: String,
575    },
576    Delete {
577        path: String,
578    },
579    Update {
580        path: String,
581        move_to: Option<String>,
582        hunks: Vec<Hunk>,
583    },
584}
585
586/// A single update hunk: lines to match (context + removed) and the replacement
587/// (context + added), in order. `anchor` is the optional text after the `@@`
588/// header — it scopes where the hunk applies (and where a pure insertion goes).
589#[derive(Default)]
590struct Hunk {
591    old: Vec<String>,
592    new: Vec<String>,
593    anchor: Option<String>,
594}
595
596fn parse_patch(patch: &str) -> Result<Vec<PatchOp>> {
597    let err = |m: &str| Error::tool("apply_patch", m.to_string());
598    let lines: Vec<&str> = patch.lines().collect();
599    let mut i = 0;
600    // Skip to Begin Patch.
601    while i < lines.len() && lines[i].trim() != "*** Begin Patch" {
602        i += 1;
603    }
604    if i == lines.len() {
605        return Err(err("missing '*** Begin Patch'"));
606    }
607    i += 1;
608
609    let mut ops = Vec::new();
610    while i < lines.len() {
611        let line = lines[i];
612        let t = line.trim_end();
613        if t == "*** End Patch" {
614            return Ok(ops);
615        } else if let Some(p) = t.strip_prefix("*** Add File: ") {
616            i += 1;
617            let mut body = Vec::new();
618            while i < lines.len() && lines[i].starts_with('+') {
619                body.push(&lines[i][1..]);
620                i += 1;
621            }
622            ops.push(PatchOp::Add {
623                path: p.to_string(),
624                body: body.join("\n"),
625            });
626        } else if let Some(p) = t.strip_prefix("*** Delete File: ") {
627            ops.push(PatchOp::Delete {
628                path: p.to_string(),
629            });
630            i += 1;
631        } else if let Some(p) = t.strip_prefix("*** Update File: ") {
632            i += 1;
633            let mut move_to = None;
634            if i < lines.len() {
635                if let Some(m) = lines[i].trim_end().strip_prefix("*** Move to: ") {
636                    move_to = Some(m.to_string());
637                    i += 1;
638                }
639            }
640            let mut hunks = Vec::new();
641            let mut cur = Hunk::default();
642            let mut started = false;
643            while i < lines.len() {
644                let l = lines[i];
645                let lt = l.trim_end();
646                if lt.starts_with("*** ") {
647                    break; // next section
648                }
649                if let Some(anchor) = lt.strip_prefix("@@") {
650                    if started && (!cur.old.is_empty() || !cur.new.is_empty()) {
651                        hunks.push(std::mem::take(&mut cur));
652                    }
653                    // The text after `@@` (e.g. `@@ def foo():`) anchors the hunk.
654                    let anchor = anchor.trim();
655                    cur.anchor = (!anchor.is_empty()).then(|| anchor.to_string());
656                    started = true;
657                    i += 1;
658                    continue;
659                }
660                started = true;
661                if let Some(rest) = l.strip_prefix('+') {
662                    cur.new.push(rest.to_string());
663                } else if let Some(rest) = l.strip_prefix('-') {
664                    cur.old.push(rest.to_string());
665                } else {
666                    // context line (leading space, or bare)
667                    let ctx = l.strip_prefix(' ').unwrap_or(l).to_string();
668                    cur.old.push(ctx.clone());
669                    cur.new.push(ctx);
670                }
671                i += 1;
672            }
673            if !cur.old.is_empty() || !cur.new.is_empty() {
674                hunks.push(cur);
675            }
676            ops.push(PatchOp::Update {
677                path: p.to_string(),
678                move_to,
679                hunks,
680            });
681        } else {
682            // Stray line between sections — skip.
683            i += 1;
684        }
685    }
686    Err(err("missing '*** End Patch'"))
687}
688
689fn apply_update(original: &str, hunks: &[Hunk], tool: &str) -> Result<String> {
690    let mut text = original.to_string();
691    for h in hunks {
692        // Resolve the `@@` anchor (if any) to a byte offset to search from, so
693        // the hunk applies at the right place and an ambiguous old-block isn't
694        // matched at the wrong (first) occurrence.
695        let from = match &h.anchor {
696            Some(a) => {
697                let Some(pos) = text.find(a.as_str()) else {
698                    return Err(Error::tool(tool, format!("@@ anchor not found: {a}")));
699                };
700                // Start just past the end of the anchor's line.
701                text[pos..]
702                    .find('\n')
703                    .map(|nl| pos + nl + 1)
704                    .unwrap_or(text.len())
705            }
706            None => 0,
707        };
708
709        let new_block = h.new.join("\n");
710
711        if h.old.is_empty() {
712            // Pure insertion. With an anchor, insert right after it; otherwise
713            // append at EOF (the only sensible place with no location info).
714            if h.anchor.is_some() {
715                let needs_lead_nl = from > 0 && text.as_bytes()[from - 1] != b'\n';
716                let payload = if needs_lead_nl {
717                    format!("\n{new_block}\n")
718                } else {
719                    format!("{new_block}\n")
720                };
721                text.insert_str(from, &payload);
722            } else {
723                if !text.is_empty() && !text.ends_with('\n') {
724                    text.push('\n');
725                }
726                text.push_str(&new_block);
727            }
728            continue;
729        }
730
731        let old_block = h.old.join("\n");
732        match text[from..].find(&old_block) {
733            Some(rel) => {
734                let start = from + rel;
735                text.replace_range(start..start + old_block.len(), &new_block);
736            }
737            None => {
738                return Err(Error::tool(
739                    tool,
740                    format!("hunk did not match file contents:\n{old_block}"),
741                ))
742            }
743        }
744    }
745    Ok(text)
746}
747
748#[async_trait]
749impl Tool for ApplyPatchTool {
750    fn name(&self) -> &str {
751        "apply_patch"
752    }
753    fn description(&self) -> &str {
754        "Apply a patch in the apply_patch envelope format (*** Begin Patch / *** End Patch) with Add File, Delete File, and Update File operations. Update hunks use leading '+'/'-'/' ' on each line and may include '@@' context headers and an optional '*** Move to:' rename."
755    }
756    fn parameters(&self) -> Value {
757        json!({
758            "type": "object",
759            "properties": {
760                "patch": {"type": "string", "description": "The full *** Begin Patch … *** End Patch text."}
761            },
762            "required": ["patch"],
763            "additionalProperties": false
764        })
765    }
766    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
767        let a: ApplyPatchArgs = parse_args(self.name(), args)?;
768        let ops = parse_patch(&a.patch)?;
769        let mut summary = Vec::new();
770        for op in ops {
771            match op {
772                PatchOp::Add { path, body } => {
773                    let full = ctx.resolve(&path);
774                    ctx.check_write(&full)?;
775                    if let Some(parent) = full.parent() {
776                        tokio::fs::create_dir_all(parent).await.ok();
777                    }
778                    tokio::fs::write(&full, body.as_bytes())
779                        .await
780                        .map_err(|e| {
781                            Error::tool(self.name(), format!("{}: {e}", full.display()))
782                        })?;
783                    summary.push(format!("A {}", rel(ctx, &full)));
784                }
785                PatchOp::Delete { path } => {
786                    let full = ctx.resolve(&path);
787                    ctx.check_write(&full)?;
788                    tokio::fs::remove_file(&full).await.map_err(|e| {
789                        Error::tool(self.name(), format!("{}: {e}", full.display()))
790                    })?;
791                    summary.push(format!("D {}", rel(ctx, &full)));
792                }
793                PatchOp::Update {
794                    path,
795                    move_to,
796                    hunks,
797                } => {
798                    let full = ctx.resolve(&path);
799                    let dest_for_check = move_to
800                        .as_ref()
801                        .map(|m| ctx.resolve(m))
802                        .unwrap_or_else(|| full.clone());
803                    ctx.check_write(&dest_for_check)?;
804                    let original = tokio::fs::read_to_string(&full).await.map_err(|e| {
805                        Error::tool(self.name(), format!("{}: {e}", full.display()))
806                    })?;
807                    let updated = apply_update(&original, &hunks, self.name())?;
808                    let dest = match &move_to {
809                        Some(m) => ctx.resolve(m),
810                        None => full.clone(),
811                    };
812                    if let Some(parent) = dest.parent() {
813                        tokio::fs::create_dir_all(parent).await.ok();
814                    }
815                    tokio::fs::write(&dest, updated.as_bytes())
816                        .await
817                        .map_err(|e| {
818                            Error::tool(self.name(), format!("{}: {e}", dest.display()))
819                        })?;
820                    if move_to.is_some() && dest != full {
821                        tokio::fs::remove_file(&full).await.ok();
822                        summary.push(format!("M {} -> {}", rel(ctx, &full), rel(ctx, &dest)));
823                    } else {
824                        summary.push(format!("U {}", rel(ctx, &full)));
825                    }
826                }
827            }
828        }
829        if summary.is_empty() {
830            Ok("(empty patch)".to_string())
831        } else {
832            Ok(format!("Applied patch:\n{}", summary.join("\n")))
833        }
834    }
835}
836
837// ---- persistent shell -----------------------------------------------------
838
839use tokio::io::{AsyncReadExt, AsyncWriteExt};
840use tokio::sync::Mutex as AsyncMutex;
841
842/// A long-lived shell whose state (working directory, environment variables,
843/// shell functions) persists across calls — unlike the one-shot [`BashTool`].
844/// Also supports `write_stdin` to feed raw input to the shell, for driving
845/// interactive programs. This is the analog of Codex's persistent exec session.
846pub struct PersistentShellTool {
847    state: AsyncMutex<Option<ShellState>>,
848    default_timeout_ms: u64,
849}
850
851impl Default for PersistentShellTool {
852    fn default() -> Self {
853        PersistentShellTool {
854            state: AsyncMutex::new(None),
855            default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
856        }
857    }
858}
859
860struct ShellState {
861    // Held to keep the shell process alive for the tool's lifetime; dropping it
862    // would terminate the persistent shell.
863    #[allow(dead_code)]
864    child: tokio::process::Child,
865    stdin: tokio::process::ChildStdin,
866    stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
867}
868
869const SHELL_SENTINEL: &str = "__SC_SHELL_DONE__";
870
871#[derive(Deserialize)]
872struct ShellArgs {
873    #[serde(default)]
874    command: Option<String>,
875    #[serde(default)]
876    write_stdin: Option<String>,
877    #[serde(default)]
878    timeout_ms: Option<u64>,
879}
880
881impl PersistentShellTool {
882    async fn ensure_started(
883        &self,
884        state: &mut Option<ShellState>,
885        ctx: &ToolContext,
886    ) -> Result<()> {
887        if state.is_some() {
888            return Ok(());
889        }
890        let mut child = build_sandboxed_interactive_sh(ctx)
891            .current_dir(&ctx.cwd)
892            .stdin(std::process::Stdio::piped())
893            .stdout(std::process::Stdio::piped())
894            .stderr(std::process::Stdio::piped())
895            // Don't leave the shell (or its sandbox-exec wrapper) running if the
896            // tool is dropped without an explicit shutdown.
897            .kill_on_drop(true)
898            .spawn()
899            .map_err(|e| Error::tool("shell", format!("spawn sh: {e}")))?;
900        let stdin = child
901            .stdin
902            .take()
903            .ok_or_else(|| Error::tool("shell", "no stdin"))?;
904        let stdout = tokio::io::BufReader::new(
905            child
906                .stdout
907                .take()
908                .ok_or_else(|| Error::tool("shell", "no stdout"))?,
909        );
910        *state = Some(ShellState {
911            child,
912            stdin,
913            stdout,
914        });
915        Ok(())
916    }
917}
918
919#[async_trait]
920impl Tool for PersistentShellTool {
921    fn name(&self) -> &str {
922        "shell"
923    }
924    fn description(&self) -> &str {
925        "Run a command in a PERSISTENT shell whose working directory, environment, and shell functions survive across calls (unlike one-shot bash). Pass `command` to run to completion (returns exit code), or `write_stdin` to feed raw input to the shell (for interactive programs)."
926    }
927    fn parameters(&self) -> Value {
928        json!({
929            "type": "object",
930            "properties": {
931                "command": {"type": "string", "description": "Command to run in the persistent shell."},
932                "write_stdin": {"type": "string", "description": "Raw text to write to the shell's stdin instead of running a command."},
933                "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
934            },
935            "additionalProperties": false
936        })
937    }
938    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
939        let a: ShellArgs = parse_args(self.name(), args)?;
940        let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
941        let mut guard = self.state.lock().await;
942        self.ensure_started(&mut guard, ctx).await?;
943        let st = guard.as_mut().expect("started");
944
945        if let Some(input) = a.write_stdin {
946            st.stdin
947                .write_all(input.as_bytes())
948                .await
949                .map_err(|e| Error::tool(self.name(), format!("write_stdin: {e}")))?;
950            st.stdin.flush().await.ok();
951            // Best-effort: read whatever output arrives within a short window.
952            let out = read_available(&mut st.stdout, Duration::from_millis(800)).await;
953            return Ok(if out.is_empty() {
954                "(no output)".into()
955            } else {
956                out
957            });
958        }
959
960        let command = a
961            .command
962            .ok_or_else(|| Error::tool(self.name(), "provide `command` or `write_stdin`"))?;
963        // Wrap so the command's stderr merges into stdout, then emit a sentinel
964        // line carrying the exit code.
965        let wrapped =
966            format!("{{ {command}\n}} 2>&1\nprintf '%s %d\\n' '{SHELL_SENTINEL}' \"$?\"\n");
967        st.stdin
968            .write_all(wrapped.as_bytes())
969            .await
970            .map_err(|e| Error::tool(self.name(), format!("write: {e}")))?;
971        st.stdin.flush().await.ok();
972
973        // Accumulate bytes until the sentinel line appears. Byte-based (not
974        // line-based) so a flush that splits mid-line can't stall us.
975        let mut acc = String::new();
976        let mut code = -1;
977        let read_fut = async {
978            let mut chunk = [0u8; 4096];
979            loop {
980                let n = st.stdout.read(&mut chunk).await.unwrap_or(0);
981                if n == 0 {
982                    break; // EOF
983                }
984                acc.push_str(&String::from_utf8_lossy(&chunk[..n]));
985                if let Some(pos) = acc.find(SHELL_SENTINEL) {
986                    let after = &acc[pos + SHELL_SENTINEL.len()..];
987                    if let Some(nl) = after.find('\n') {
988                        code = after[..nl].trim().parse().unwrap_or(-1);
989                        acc.truncate(pos);
990                        break;
991                    }
992                }
993            }
994        };
995        if tokio::time::timeout(timeout, read_fut).await.is_err() {
996            return Err(Error::tool(
997                self.name(),
998                format!("command timed out after {timeout:?}"),
999            ));
1000        }
1001        let output = if acc.trim().is_empty() {
1002            "(no output)".to_string()
1003        } else {
1004            acc.trim_end().to_string()
1005        };
1006        Ok(format!("exit code: {code}\n{output}"))
1007    }
1008}
1009
1010/// Read whatever bytes are available on a reader within `window`, returning the
1011/// decoded text. Used by `write_stdin` where there is no completion sentinel.
1012async fn read_available<R: AsyncReadExt + Unpin>(reader: &mut R, window: Duration) -> String {
1013    let mut buf = Vec::new();
1014    let mut chunk = [0u8; 4096];
1015    loop {
1016        match tokio::time::timeout(window, reader.read(&mut chunk)).await {
1017            Ok(Ok(0)) => break, // EOF
1018            Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
1019            Ok(Err(_)) => break,
1020            Err(_) => break, // window elapsed
1021        }
1022    }
1023    String::from_utf8_lossy(&buf).into_owned()
1024}
1025
1026// ---- update_plan ----------------------------------------------------------
1027
1028/// A simple plan / task tracker. The model calls it to record or update a
1029/// checklist of steps (the analog of Codex `update_plan` / Claude's plan mode).
1030/// The plan is held in the tool so it persists across calls within a session.
1031pub struct UpdatePlanTool {
1032    plan: std::sync::Mutex<Vec<PlanStep>>,
1033}
1034
1035impl Default for UpdatePlanTool {
1036    fn default() -> Self {
1037        UpdatePlanTool {
1038            plan: std::sync::Mutex::new(Vec::new()),
1039        }
1040    }
1041}
1042
1043#[derive(Deserialize, Clone)]
1044struct PlanStep {
1045    step: String,
1046    #[serde(default = "default_status")]
1047    status: String,
1048}
1049fn default_status() -> String {
1050    "pending".to_string()
1051}
1052
1053#[derive(Deserialize)]
1054struct PlanArgs {
1055    plan: Vec<PlanStep>,
1056}
1057
1058impl UpdatePlanTool {
1059    /// The current plan as `(step, status)` pairs.
1060    pub fn current(&self) -> Vec<(String, String)> {
1061        self.plan
1062            .lock()
1063            .unwrap()
1064            .iter()
1065            .map(|s| (s.step.clone(), s.status.clone()))
1066            .collect()
1067    }
1068}
1069
1070#[async_trait]
1071impl Tool for UpdatePlanTool {
1072    fn name(&self) -> &str {
1073        "update_plan"
1074    }
1075    fn description(&self) -> &str {
1076        "Record or update the task plan: a checklist of steps with statuses (pending/in_progress/completed). Replaces the current plan. Use it to track multi-step work."
1077    }
1078    fn parameters(&self) -> Value {
1079        json!({
1080            "type": "object",
1081            "properties": {
1082                "plan": {
1083                    "type": "array",
1084                    "items": {
1085                        "type": "object",
1086                        "properties": {
1087                            "step": {"type": "string"},
1088                            "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
1089                        },
1090                        "required": ["step"]
1091                    }
1092                }
1093            },
1094            "required": ["plan"],
1095            "additionalProperties": false
1096        })
1097    }
1098    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1099        let a: PlanArgs = parse_args(self.name(), args)?;
1100        *self.plan.lock().unwrap() = a.plan.clone();
1101        let rendered = a
1102            .plan
1103            .iter()
1104            .map(|s| {
1105                let mark = match s.status.as_str() {
1106                    "completed" => "[x]",
1107                    "in_progress" => "[~]",
1108                    _ => "[ ]",
1109                };
1110                format!("{mark} {}", s.step)
1111            })
1112            .collect::<Vec<_>>()
1113            .join("\n");
1114        Ok(if rendered.is_empty() {
1115            "(empty plan)".into()
1116        } else {
1117            format!("Plan updated:\n{rendered}")
1118        })
1119    }
1120}