Skip to main content

perspt_agent/tools/
executor.rs

1use super::*;
2
3/// Default `read_file` window when the model passes no `limit`.
4const READ_DEFAULT_LIMIT: usize = 300;
5/// Largest honored `limit` for one read window; bigger requests clamp so a
6/// single call can never stage an unbounded window in memory.
7const READ_MAX_LIMIT: usize = 2000;
8/// Individual lines longer than this are truncated with an ellipsis.
9const READ_MAX_LINE_CHARS: usize = 2000;
10/// Bytes retained per line while streaming (the char cap applies after
11/// decoding); the rest of an overlong line is counted, never buffered.
12const READ_MAX_LINE_BYTES: usize = 4 * READ_MAX_LINE_CHARS + 4;
13/// Search output is capped at this many lines before the omission note.
14const GREP_MAX_LINES: usize = 200;
15/// Largest honored `-C` context value for a search.
16const GREP_MAX_CONTEXT: usize = 10;
17
18/// Default listing window for `glob` and `list_files`.
19const LIST_DEFAULT_LIMIT: usize = 100;
20const LIST_MAX_LIMIT: usize = 1000;
21const LIST_MAX_ENTRIES: usize = 10_000;
22const GLOB_MAX_MATCHES: usize = 10_000;
23
24/// A 0-based (offset, limit) listing window.
25fn parse_window(call: &ToolCall) -> Result<(usize, usize), String> {
26    let offset = match call.arguments.get("offset") {
27        None => 0usize,
28        Some(raw) => raw
29            .trim()
30            .parse::<usize>()
31            .map_err(|_| format!("'offset' must be a non-negative integer, got {raw:?}"))?,
32    };
33    let limit = match call.arguments.get("limit") {
34        None => LIST_DEFAULT_LIMIT,
35        Some(raw) => match raw.trim().parse::<usize>() {
36            Ok(0) | Err(_) => return Err(format!("'limit' must be at least 1, got {raw:?}")),
37            Ok(v) => v.min(LIST_MAX_LIMIT),
38        },
39    };
40    Ok((offset, limit))
41}
42
43/// Options that can make an ostensibly read-only Git subcommand write a
44/// file, invoke configured external code, or read arbitrary host paths.
45fn unsafe_git_read_arg(argument: &str) -> bool {
46    [
47        "--output",
48        "--ext-diff",
49        "--textconv",
50        "--no-index",
51        "--pathspec-from-file",
52    ]
53    .iter()
54    .any(|forbidden| argument == *forbidden || argument.starts_with(&format!("{forbidden}=")))
55}
56
57/// Render a bounded window of `entries` with a total header and, when the
58/// window ends early, a continuation hint naming the next offset.
59fn paged_listing(label: &str, entries: &[String], (offset, limit): (usize, usize)) -> String {
60    let total = entries.len();
61    if total == 0 {
62        return format!("{label}: 0 entries");
63    }
64    if offset >= total {
65        return format!("{label}: {total} entries total; offset {offset} is past the end");
66    }
67    let end = total.min(offset + limit);
68    let mut output = format!("{label}: entries {offset}-{} of {total}\n", end - 1);
69    output.push_str(&entries[offset..end].join("\n"));
70    if end < total {
71        output.push_str(&format!(
72            "\n[{} more entries; continue with offset={end}]",
73            total - end
74        ));
75    }
76    output
77}
78
79/// Keep the first `GREP_MAX_LINES` output lines plus an omission note.
80fn capped_search_output(stdout: &[u8]) -> String {
81    let text = String::from_utf8_lossy(stdout);
82    let lines: Vec<&str> = text.lines().collect();
83    let mut kept: Vec<String> = lines
84        .iter()
85        .take(GREP_MAX_LINES)
86        .map(|l| l.to_string())
87        .collect();
88    if lines.len() > GREP_MAX_LINES {
89        kept.push(format!(
90            "[{} more lines omitted; narrow the query, pass a file path, or lower context]",
91            lines.len() - GREP_MAX_LINES
92        ));
93    }
94    kept.join("\n")
95}
96
97/// Largest stdout volume retained from a search child; past it the child
98/// is killed and the result marked truncated, so one pathological match
99/// (a multi-gigabyte line) cannot balloon host memory.
100const GREP_MAX_STDOUT_BYTES: usize = 2 * 1024 * 1024;
101const GREP_MAX_STDERR_BYTES: usize = 64 * 1024;
102const GREP_PROCESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
103
104/// A search child's bounded result.
105#[derive(Debug)]
106struct SearchOutput {
107    /// Exit status success, or a deliberate truncation kill (matches were
108    /// flowing when the cap hit — that is a successful, capped search).
109    success: bool,
110    code: Option<i32>,
111    stdout: Vec<u8>,
112    stderr: Vec<u8>,
113    truncated: bool,
114}
115
116/// Drain one child pipe completely while retaining at most `cap` bytes.
117/// Draining continues after the cap so the child cannot deadlock on a full
118/// pipe while the parent waits for it to exit.
119fn drain_bounded(
120    mut pipe: impl std::io::Read,
121    cap: usize,
122    cap_signal: Option<std::sync::mpsc::Sender<()>>,
123) -> std::io::Result<(Vec<u8>, bool)> {
124    let mut retained = Vec::new();
125    let mut truncated = false;
126    let mut buf = [0u8; 64 * 1024];
127    loop {
128        let n = pipe.read(&mut buf)?;
129        if n == 0 {
130            break;
131        }
132        let room = cap.saturating_sub(retained.len());
133        retained.extend_from_slice(&buf[..n.min(room)]);
134        if n > room && !truncated {
135            truncated = true;
136            if let Some(sender) = &cap_signal {
137                let _ = sender.send(());
138            }
139        }
140    }
141    Ok((retained, truncated))
142}
143
144/// Run a child with concurrent bounded drains for stdout and stderr. The
145/// child is killed at the stdout cap or deadline; neither pipe can block the
146/// other, and unbounded stderr is discarded rather than retained.
147fn bounded_command_output(mut command: Command) -> std::io::Result<SearchOutput> {
148    bounded_command_output_with_timeout(&mut command, GREP_PROCESS_TIMEOUT)
149}
150
151fn bounded_command_output_with_timeout(
152    command: &mut Command,
153    timeout: std::time::Duration,
154) -> std::io::Result<SearchOutput> {
155    let mut child = command
156        .stdout(std::process::Stdio::piped())
157        .stderr(std::process::Stdio::piped())
158        .spawn()?;
159    let stdout_pipe = child.stdout.take().expect("piped stdout");
160    let stderr_pipe = child.stderr.take().expect("piped stderr");
161    let (cap_tx, cap_rx) = std::sync::mpsc::channel();
162    let stdout_thread =
163        std::thread::spawn(move || drain_bounded(stdout_pipe, GREP_MAX_STDOUT_BYTES, Some(cap_tx)));
164    let stderr_thread =
165        std::thread::spawn(move || drain_bounded(stderr_pipe, GREP_MAX_STDERR_BYTES, None));
166
167    let started = std::time::Instant::now();
168    let mut hit_cap = false;
169    let mut timed_out = false;
170    let status = loop {
171        if cap_rx.try_recv().is_ok() {
172            hit_cap = true;
173            let _ = child.kill();
174            break child.wait()?;
175        }
176        if let Some(status) = child.try_wait()? {
177            break status;
178        }
179        if started.elapsed() >= timeout {
180            timed_out = true;
181            let _ = child.kill();
182            break child.wait()?;
183        }
184        std::thread::sleep(std::time::Duration::from_millis(5));
185    };
186    let (stdout, stdout_truncated) = stdout_thread
187        .join()
188        .map_err(|_| std::io::Error::other("search stdout reader panicked"))??;
189    let (stderr, _stderr_truncated) = stderr_thread
190        .join()
191        .map_err(|_| std::io::Error::other("search stderr reader panicked"))??;
192    if timed_out {
193        return Err(std::io::Error::new(
194            std::io::ErrorKind::TimedOut,
195            "search process exceeded 30 second deadline",
196        ));
197    }
198    let truncated = hit_cap || stdout_truncated;
199    Ok(SearchOutput {
200        success: status.success() || truncated,
201        code: status.code(),
202        stdout,
203        stderr,
204        truncated,
205    })
206}
207
208#[cfg(all(test, unix))]
209mod bounded_process_tests {
210    use super::*;
211
212    #[test]
213    fn drains_large_stderr_without_deadlocking() {
214        let mut command = Command::new("sh");
215        let script = concat!(
216            "i=0; while [ $i -lt 100000 ]; do ",
217            "printf 'diagnostic diagnostic diagnostic\\n' 1>&2; ",
218            "i=$((i + 1)); done"
219        );
220        command.args(["-c", script]);
221        let output =
222            bounded_command_output_with_timeout(&mut command, std::time::Duration::from_secs(5))
223                .unwrap();
224        assert!(output.success);
225        assert_eq!(output.stderr.len(), GREP_MAX_STDERR_BYTES);
226    }
227
228    #[test]
229    fn kills_a_search_that_never_finishes() {
230        let mut command = Command::new("sleep");
231        command.arg("5");
232        let error =
233            bounded_command_output_with_timeout(&mut command, std::time::Duration::from_millis(50))
234                .unwrap_err();
235        assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
236    }
237}
238
239/// The streamed line window of one `read_file` call.
240struct ReadWindow {
241    lines: Vec<String>,
242    total: usize,
243    truncated: bool,
244}
245
246/// Stream the requested window: only in-window lines are retained (each
247/// capped), lines outside it are counted without buffering — memory stays
248/// O(window) whatever the file size. Binary content (NUL bytes) refuses.
249fn collect_read_window(
250    path: &std::path::Path,
251    raw_path: &str,
252    offset: usize,
253    limit: usize,
254) -> Result<ReadWindow, String> {
255    let file = fs::File::open(path).map_err(|e| format!("Failed to read {:?}: {}", path, e))?;
256    let mut reader = std::io::BufReader::new(file);
257    let mut window = ReadWindow {
258        lines: Vec::new(),
259        total: 0,
260        truncated: false,
261    };
262    loop {
263        let wanted = window.total + 1 >= offset && window.lines.len() < limit;
264        let cap = if wanted { READ_MAX_LINE_BYTES } else { 0 };
265        let Some((bytes, skipped, saw_nul)) = read_bounded_line(&mut reader, cap)
266            .map_err(|e| format!("Failed to read {:?}: {}", path, e))?
267        else {
268            return Ok(window);
269        };
270        window.total += 1;
271        // Binary refusal covers the whole stream: skipped lines and the
272        // unretained tails of overlong lines are scanned too.
273        if saw_nul {
274            return Err(format!("{raw_path} appears to be a binary file"));
275        }
276        if !wanted {
277            continue;
278        }
279        let text = String::from_utf8_lossy(&bytes);
280        let rendered = match char_cap(&text, READ_MAX_LINE_CHARS) {
281            Some(cut) => {
282                window.truncated = true;
283                let more = skipped + (text.len() - cut.len());
284                format!("{cut}…[+{more} bytes on this line]")
285            }
286            None if skipped > 0 => {
287                window.truncated = true;
288                format!("{text}…[+{skipped} bytes on this line]")
289            }
290            None => text.into_owned(),
291        };
292        window
293            .lines
294            .push(format!("{:>6}\t{rendered}", window.total));
295    }
296}
297
298/// Read one line keeping at most `cap` bytes: `(kept, skipped, saw_nul)`
299/// where `skipped` counts the bytes beyond the cap (newline excluded) and
300/// `saw_nul` reports a NUL anywhere in the line — retained or skipped — so
301/// binary detection covers the whole stream, not just kept prefixes.
302/// `None` at EOF. The tail of an overlong line is consumed without
303/// buffering, so a single multi-gigabyte line costs O(cap) memory.
304fn read_bounded_line(
305    reader: &mut impl std::io::BufRead,
306    cap: usize,
307) -> std::io::Result<Option<(Vec<u8>, usize, bool)>> {
308    let mut kept = Vec::new();
309    let mut skipped = 0usize;
310    let mut saw_any = false;
311    let mut saw_nul = false;
312    loop {
313        let buf = reader.fill_buf()?;
314        if buf.is_empty() {
315            return Ok(saw_any.then_some((kept, skipped, saw_nul)));
316        }
317        saw_any = true;
318        let newline = buf.iter().position(|&byte| byte == b'\n');
319        let take = newline.unwrap_or(buf.len());
320        saw_nul = saw_nul || buf[..take].contains(&0);
321        let keep = take.min(cap.saturating_sub(kept.len()));
322        kept.extend_from_slice(&buf[..keep]);
323        skipped += take - keep;
324        reader.consume(take + usize::from(newline.is_some()));
325        if newline.is_some() {
326            if skipped == 0 && kept.last() == Some(&b'\r') {
327                kept.pop();
328            }
329            return Ok(Some((kept, skipped, saw_nul)));
330        }
331    }
332}
333
334/// The first `cap` characters when the text is longer, `None` otherwise.
335fn char_cap(text: &str, cap: usize) -> Option<&str> {
336    let mut indices = text.char_indices();
337    match indices.nth(cap) {
338        Some((byte_index, _)) => Some(&text[..byte_index]),
339        None => None,
340    }
341}
342
343/// Parse a 1-based positive integer line argument, defaulting when absent.
344fn parse_line_argument(call: &ToolCall, name: &str, default: usize) -> Result<usize, String> {
345    match call.arguments.get(name) {
346        None => Ok(default),
347        Some(raw) => match raw.trim().parse::<usize>() {
348            Ok(0) => Err(format!("'{name}' must be at least 1")),
349            Ok(v) => Ok(v),
350            Err(_) => Err(format!("'{name}' must be a positive integer, got {raw:?}")),
351        },
352    }
353}
354
355/// Tool result from agent execution
356#[derive(Debug, Clone)]
357pub struct ToolResult {
358    pub tool_name: String,
359    pub success: bool,
360    pub output: String,
361    pub error: Option<String>,
362}
363
364impl ToolResult {
365    pub fn success(tool_name: &str, output: String) -> Self {
366        Self {
367            tool_name: tool_name.to_string(),
368            success: true,
369            output,
370            error: None,
371        }
372    }
373
374    pub fn failure(tool_name: &str, error: String) -> Self {
375        Self {
376            tool_name: tool_name.to_string(),
377            success: false,
378            output: String::new(),
379            error: Some(error),
380        }
381    }
382}
383
384/// Tool call request from LLM
385#[derive(Debug, Clone)]
386pub struct ToolCall {
387    pub name: String,
388    pub arguments: HashMap<String, String>,
389}
390
391/// Agent tools for workspace interaction
392pub struct AgentTools {
393    /// Working directory (sandbox root)
394    working_dir: PathBuf,
395}
396
397impl AgentTools {
398    /// Create new agent tools instance
399    pub fn new(working_dir: PathBuf) -> Self {
400        Self { working_dir }
401    }
402    /// Read a windowed slice of a file as line-numbered text.
403    ///
404    /// Honors the catalog's `offset` (1-based first line) and `limit`
405    /// arguments and always reports the total line count plus the offset
406    /// that continues the read, so large files stay navigable page by page.
407    pub(crate) fn read_file(&self, call: &ToolCall) -> ToolResult {
408        let Some(raw_path) = call.arguments.get("path") else {
409            return ToolResult::failure("read_file", "Missing 'path' argument".to_string());
410        };
411        let path = self.resolve_path(raw_path);
412        let offset = match parse_line_argument(call, "offset", 1) {
413            Ok(v) => v,
414            Err(e) => return ToolResult::failure("read_file", e),
415        };
416        let limit = match parse_line_argument(call, "limit", READ_DEFAULT_LIMIT) {
417            Ok(v) => v,
418            Err(e) => return ToolResult::failure("read_file", e),
419        };
420
421        let limit = limit.min(READ_MAX_LIMIT);
422        let ReadWindow {
423            lines: window,
424            total,
425            truncated: any_truncated,
426        } = match collect_read_window(&path, raw_path, offset, limit) {
427            Ok(window) => window,
428            Err(message) => return ToolResult::failure("read_file", message),
429        };
430        if offset > total {
431            return ToolResult::success(
432                "read_file",
433                format!("file {raw_path}: {total} lines total; offset {offset} is past the end"),
434            );
435        }
436        let end = offset - 1 + window.len();
437        let mut output = format!("file {raw_path}: lines {offset}-{end} of {total}\n");
438        output.push_str(&window.join("\n"));
439        output.push('\n');
440        if any_truncated {
441            output.push_str("[overlong lines truncated; grep returns full lines]\n");
442        }
443        if end < total {
444            output.push_str(&format!(
445                "[{} more lines; continue with offset={}]",
446                total - end,
447                end + 1
448            ));
449        }
450        ToolResult::success("read_file", output)
451    }
452
453    /// Search for code patterns with ripgrep (plain-text grep fallback).
454    ///
455    /// Runs from the workspace root against a relative target so both file
456    /// and directory paths work, honors the catalog's `context` argument,
457    /// and caps the returned lines so a broad query stays bounded.
458    pub(crate) fn search_code(&self, call: &ToolCall) -> ToolResult {
459        let query = match call.arguments.get("query") {
460            Some(q) => q,
461            None => {
462                return ToolResult::failure("search_code", "Missing 'query' argument".to_string())
463            }
464        };
465        let target = call
466            .arguments
467            .get("path")
468            .cloned()
469            .unwrap_or_else(|| ".".to_string());
470        if !self.resolve_path(&target).exists() {
471            return ToolResult::failure(
472                "search_code",
473                format!("Search target does not exist: {target}"),
474            );
475        }
476        let context = match call.arguments.get("context") {
477            None => 0usize,
478            Some(raw) => match raw.trim().parse::<usize>() {
479                Ok(n) => n.min(GREP_MAX_CONTEXT),
480                Err(_) => {
481                    return ToolResult::failure(
482                        "search_code",
483                        format!("'context' must be a non-negative integer, got {raw:?}"),
484                    )
485                }
486            },
487        };
488        match self.spawn_search(query, &target, context) {
489            Ok(out) if out.code == Some(1) && out.stdout.is_empty() => ToolResult::success(
490                "search_code",
491                format!("no matches for {query:?} in {target}"),
492            ),
493            Ok(out) if out.success || out.code == Some(1) => {
494                let mut rendered = capped_search_output(&out.stdout);
495                if out.truncated {
496                    rendered.push_str("\n[search output truncated; narrow the query]");
497                }
498                ToolResult::success("search_code", rendered)
499            }
500            Ok(out) => ToolResult::failure(
501                "search_code",
502                format!("Search failed: {}", String::from_utf8_lossy(&out.stderr)),
503            ),
504            Err(e) => ToolResult::failure("search_code", format!("Search failed: {}", e)),
505        }
506    }
507
508    /// Run ripgrep (plain grep fallback) from the workspace root against a
509    /// relative target so file and directory paths both work. Output is
510    /// read through the bounded runner, so a match on a multi-gigabyte
511    /// line cannot materialize the child's whole stdout.
512    fn spawn_search(
513        &self,
514        query: &str,
515        target: &str,
516        context: usize,
517    ) -> std::io::Result<SearchOutput> {
518        let context_arg = format!("-C{context}");
519        let mut rg_args = vec!["-n", "--no-heading", "--color", "never", "-H"];
520        if context > 0 {
521            rg_args.push(&context_arg);
522        }
523        rg_args.extend(["--max-count", "50", "--", query, target]);
524        let mut rg = Command::new("rg");
525        rg.args(&rg_args).current_dir(&self.working_dir);
526        // Fall back to plain grep only when ripgrep is absent. A timeout or
527        // cap kill must surface as-is: retrying the same pathological query
528        // through grep would only double the worst-case latency.
529        bounded_command_output(rg).or_else(|error| {
530            if error.kind() != std::io::ErrorKind::NotFound {
531                return Err(error);
532            }
533            // GNU grep omits the filename for a single file unless -H is
534            // explicit; ripgrep above always includes it. Keep both paths on
535            // the same stable, workspace-relative output contract.
536            let mut grep_args = vec!["-Hrn"];
537            if context > 0 {
538                grep_args.push(&context_arg);
539            }
540            grep_args.extend(["--", query, target]);
541            let mut grep = Command::new("grep");
542            grep.args(&grep_args).current_dir(&self.working_dir);
543            bounded_command_output(grep)
544        })
545    }
546    /// Apply a unified diff patch to a file
547    pub(crate) fn apply_diff(&self, call: &ToolCall) -> ToolResult {
548        let path = match call.arguments.get("path") {
549            Some(p) => self.resolve_path(p),
550            None => {
551                return ToolResult::failure("apply_diff", "Missing 'path' argument".to_string())
552            }
553        };
554
555        let diff_content = match call.arguments.get("diff") {
556            Some(c) => c,
557            None => {
558                return ToolResult::failure("apply_diff", "Missing 'diff' argument".to_string())
559            }
560        };
561
562        // Read original file
563        let original = match fs::read_to_string(&path) {
564            Ok(c) => c,
565            Err(e) => {
566                // If file doesn't exist, we can't patch it.
567                // (Unless it's a new file creation patch, but diffy usually assumes base text)
568                return ToolResult::failure(
569                    "apply_diff",
570                    format!("Failed to read base file {:?}: {}", path, e),
571                );
572            }
573        };
574
575        // Parse patch
576        let patch = match Patch::from_str(diff_content) {
577            Ok(p) => p,
578            Err(e) => {
579                return ToolResult::failure("apply_diff", format!("Failed to parse diff: {}", e));
580            }
581        };
582
583        // Apply patch
584        match apply(&original, &patch) {
585            Ok(patched) => match fs::write(&path, patched) {
586                Ok(_) => {
587                    ToolResult::success("apply_diff", format!("Successfully patched {:?}", path))
588                }
589                Err(e) => ToolResult::failure(
590                    "apply_diff",
591                    format!("Failed to write patched file: {}", e),
592                ),
593            },
594            Err(e) => ToolResult::failure("apply_diff", format!("Failed to apply patch: {}", e)),
595        }
596    }
597
598    /// Exact-string replace with a uniqueness check (PSP-9 system 5).
599    ///
600    /// Fails closed on ambiguity: zero matches means the model edited a
601    /// stale view; more than one means the anchor is not unique. Both are
602    /// returned as errors the harness converts into directed corrections.
603    pub(crate) fn edit_file(&self, call: &ToolCall) -> ToolResult {
604        let (path, old, new) = match (
605            call.arguments.get("path"),
606            call.arguments.get("old_string"),
607            call.arguments.get("new_string"),
608        ) {
609            (Some(p), Some(o), Some(n)) => (self.resolve_path(p), o, n),
610            _ => {
611                return ToolResult::failure(
612                    "edit_file",
613                    "Missing required arguments: path, old_string, new_string".to_string(),
614                )
615            }
616        };
617        let content = match fs::read_to_string(&path) {
618            Ok(c) => c,
619            Err(e) => {
620                return ToolResult::failure("edit_file", format!("Failed to read {path:?}: {e}"))
621            }
622        };
623        match content.matches(old.as_str()).count() {
624            0 => ToolResult::failure(
625                "edit_file",
626                "old_string not found; re-read the file — it may have changed".to_string(),
627            ),
628            1 => {
629                let updated = content.replacen(old.as_str(), new, 1);
630                match fs::write(&path, updated) {
631                    Ok(_) => ToolResult::success("edit_file", format!("Edited {path:?}")),
632                    Err(e) => {
633                        ToolResult::failure("edit_file", format!("Failed to write {path:?}: {e}"))
634                    }
635                }
636            }
637            n => ToolResult::failure(
638                "edit_file",
639                format!("old_string matches {n} locations; provide a unique anchor"),
640            ),
641        }
642    }
643
644    /// Match files by glob pattern, newest first, in bounded windows.
645    pub(crate) fn glob(&self, call: &ToolCall) -> ToolResult {
646        let Some(pattern) = call.arguments.get("pattern") else {
647            return ToolResult::failure("glob", "Missing 'pattern' argument".to_string());
648        };
649        let window = match parse_window(call) {
650            Ok(w) => w,
651            Err(e) => return ToolResult::failure("glob", e),
652        };
653        let (mut matches, total) = collect_glob_matches(&self.working_dir, pattern);
654        matches.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
655        let retained = matches.len();
656        let listing: Vec<String> = matches.into_iter().map(|(_, p)| p).collect();
657        let label = if total > retained {
658            format!("glob {pattern} (newest {retained} of {total} matches)")
659        } else {
660            format!("glob {pattern}")
661        };
662        ToolResult::success("glob", paged_listing(&label, &listing, window))
663    }
664
665    /// Read repository state through git's read-only subcommands.
666    pub(crate) fn git_read(&self, call: &ToolCall) -> ToolResult {
667        let Some(subcommand) = call.arguments.get("subcommand") else {
668            return ToolResult::failure("git_read", "Missing 'subcommand' argument".to_string());
669        };
670        if !matches!(subcommand.as_str(), "status" | "diff" | "log" | "show") {
671            return ToolResult::failure(
672                "git_read",
673                format!(
674                    "Subcommand {subcommand:?} is not read-only; allowed: status, diff, log, show"
675                ),
676            );
677        }
678        let mut args = vec![subcommand.clone()];
679        if let Some(extra) = call.arguments.get("args") {
680            args.extend(extra.split_whitespace().map(str::to_string));
681        }
682        if let Some(argument) = args.iter().find(|argument| unsafe_git_read_arg(argument)) {
683            return ToolResult::failure(
684                "git_read",
685                format!("Git argument {argument:?} is not permitted for read-only inspection"),
686            );
687        }
688        if subcommand != "status" {
689            args.splice(1..1, ["--no-ext-diff".into(), "--no-textconv".into()]);
690        }
691        let mut command = Command::new("git");
692        command
693            .args(&args)
694            .current_dir(&self.working_dir)
695            .env("GIT_PAGER", "cat")
696            .env("PAGER", "cat")
697            .env_remove("GIT_EXTERNAL_DIFF");
698        match bounded_command_output(command) {
699            Ok(out) if out.success => {
700                let mut rendered = String::from_utf8_lossy(&out.stdout).to_string();
701                if out.truncated {
702                    rendered.push_str("\n[git output truncated; narrow the revision or path]");
703                }
704                ToolResult::success("git_read", rendered)
705            }
706            Ok(out) => {
707                ToolResult::failure("git_read", String::from_utf8_lossy(&out.stderr).to_string())
708            }
709            Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
710                ToolResult::failure("git_read", format!("git timed out: {e}"))
711            }
712            Err(e) => ToolResult::failure("git_read", format!("git failed to start: {e}")),
713        }
714    }
715    /// List a directory's entries, honoring ignore files, in bounded windows.
716    pub(crate) fn list_files(&self, call: &ToolCall) -> ToolResult {
717        let label = call
718            .arguments
719            .get("path")
720            .cloned()
721            .unwrap_or_else(|| ".".to_string());
722        let path = call
723            .arguments
724            .get("path")
725            .map(|p| self.resolve_path(p))
726            .unwrap_or_else(|| self.working_dir.clone());
727        let window = match parse_window(call) {
728            Ok(w) => w,
729            Err(e) => return ToolResult::failure("list_files", e),
730        };
731        if !path.is_dir() {
732            return ToolResult::failure(
733                "list_files",
734                format!("Failed to list {:?}: not a directory", path),
735            );
736        }
737
738        let mut files = std::collections::BTreeSet::new();
739        let mut total = 0usize;
740        for name in ignore::WalkBuilder::new(&path)
741            .max_depth(Some(1))
742            .standard_filters(true)
743            .require_git(false)
744            .build()
745            .filter_map(|e| e.ok())
746            .filter(|e| e.depth() > 0)
747            .map(|e| {
748                let name = e.file_name().to_string_lossy().to_string();
749                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
750                    format!("{}/", name)
751                } else {
752                    name
753                }
754            })
755        {
756            total = total.saturating_add(1);
757            files.insert(name);
758            if files.len() > LIST_MAX_ENTRIES {
759                files.pop_last();
760            }
761        }
762        let retained = files.len();
763        let files: Vec<String> = files.into_iter().collect();
764        let label = if total > retained {
765            format!("list_files {label} (first {retained} of {total} entries)")
766        } else {
767            format!("list_files {label}")
768        };
769        ToolResult::success("list_files", paged_listing(&label, &files, window))
770    }
771
772    /// Create or replace a whole file, creating parent directories as needed
773    pub(crate) fn write_file(&self, call: &ToolCall) -> ToolResult {
774        let path = match call.arguments.get("path") {
775            Some(p) => self.resolve_path(p),
776            None => {
777                return ToolResult::failure("write_file", "Missing 'path' argument".to_string())
778            }
779        };
780        let content = match call.arguments.get("content") {
781            Some(c) => c,
782            None => {
783                return ToolResult::failure("write_file", "Missing 'content' argument".to_string())
784            }
785        };
786        if let Some(parent) = path.parent() {
787            if let Err(e) = fs::create_dir_all(parent) {
788                return ToolResult::failure(
789                    "write_file",
790                    format!("Failed to create directories: {}", e),
791                );
792            }
793        }
794        match fs::write(&path, content) {
795            Ok(_) => ToolResult::success("write_file", format!("Successfully wrote {:?}", path)),
796            Err(e) => {
797                ToolResult::failure("write_file", format!("Failed to write {:?}: {}", path, e))
798            }
799        }
800    }
801
802    /// Delete a file from the workspace
803    pub(crate) fn delete_file(&self, call: &ToolCall) -> ToolResult {
804        let path = match call.arguments.get("path") {
805            Some(p) => self.resolve_path(p),
806            None => {
807                return ToolResult::failure("delete_file", "Missing 'path' argument".to_string())
808            }
809        };
810
811        if !path.exists() {
812            return ToolResult::success(
813                "delete_file",
814                format!("Path does not exist, nothing to delete: {:?}", path),
815            );
816        }
817
818        if path.is_dir() {
819            return ToolResult::failure(
820                "delete_file",
821                format!(
822                    "Cannot delete directory {:?}; only files are supported",
823                    path
824                ),
825            );
826        }
827
828        match std::fs::remove_file(&path) {
829            Ok(()) => ToolResult::success("delete_file", format!("Deleted {:?}", path)),
830            Err(e) => {
831                ToolResult::failure("delete_file", format!("Failed to delete {:?}: {}", path, e))
832            }
833        }
834    }
835
836    /// Move/rename a file within the workspace
837    pub(crate) fn move_file(&self, call: &ToolCall) -> ToolResult {
838        let from = match call
839            .arguments
840            .get("path")
841            .or_else(|| call.arguments.get("from"))
842        {
843            Some(p) => self.resolve_path(p),
844            None => return ToolResult::failure("move_file", "Missing 'path' argument".to_string()),
845        };
846        let to = match call.arguments.get("to") {
847            Some(p) => self.resolve_path(p),
848            None => return ToolResult::failure("move_file", "Missing 'to' argument".to_string()),
849        };
850
851        if !from.exists() {
852            return ToolResult::failure(
853                "move_file",
854                format!("Source path does not exist: {:?}", from),
855            );
856        }
857
858        // Ensure destination parent directory exists
859        if let Some(parent) = to.parent() {
860            if !parent.exists() {
861                if let Err(e) = std::fs::create_dir_all(parent) {
862                    return ToolResult::failure(
863                        "move_file",
864                        format!("Failed to create destination directory {:?}: {}", parent, e),
865                    );
866                }
867            }
868        }
869
870        match std::fs::rename(&from, &to) {
871            Ok(()) => ToolResult::success("move_file", format!("Moved {:?} -> {:?}", from, to)),
872            Err(e) => ToolResult::failure(
873                "move_file",
874                format!("Failed to move {:?} -> {:?}: {}", from, to, e),
875            ),
876        }
877    }
878
879    /// Resolve a path relative to working directory
880    fn resolve_path(&self, path: &str) -> PathBuf {
881        let p = Path::new(path);
882        if p.is_absolute() {
883            p.to_path_buf()
884        } else {
885            self.working_dir.join(p)
886        }
887    }
888
889    // =========================================================================
890    // Power Tools (OS-level operations)
891    // =========================================================================
892}
893/// Walk the workspace without following symlinks, retaining only the newest
894/// [`GLOB_MAX_MATCHES`] matches while counting the full result set. This
895/// keeps glob memory bounded on generated or very large repositories.
896fn collect_glob_matches(
897    root: &Path,
898    pattern: &str,
899) -> (Vec<(std::time::SystemTime, String)>, usize) {
900    let mut newest: std::collections::BinaryHeap<
901        std::cmp::Reverse<(std::time::SystemTime, String)>,
902    > = std::collections::BinaryHeap::new();
903    let mut total = 0usize;
904    for entry in ignore::WalkBuilder::new(root)
905        .standard_filters(true)
906        .require_git(false)
907        .build()
908        .flatten()
909        .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
910    {
911        let Ok(relative) = entry.path().strip_prefix(root) else {
912            continue;
913        };
914        let relative = relative.to_string_lossy().replace('\\', "/");
915        if !perspt_core::types::glob_matches(pattern, &relative) {
916            continue;
917        }
918        total = total.saturating_add(1);
919        let modified = entry
920            .metadata()
921            .ok()
922            .and_then(|metadata| metadata.modified().ok())
923            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
924        newest.push(std::cmp::Reverse((modified, relative)));
925        if newest.len() > GLOB_MAX_MATCHES {
926            newest.pop();
927        }
928    }
929    (newest.into_iter().map(|entry| entry.0).collect(), total)
930}