Skip to main content

scx_forge_agent/
tools.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! Sandboxed tools exposed to the model as OpenAI function calls. Target-crate
4//! tools (`read_file`, `list_dir`, `grep`, optional `rg`, `web_fetch`, and gated
5//! `edit_file`) resolve paths relative to the scheduler crate or public URLs;
6//! comparison tools read other schedulers under `scheds/rust` but never write
7//! them. Host-topology tools run fixed read-only commands or read fixed sysfs
8//! CPU cache metadata paths.
9//! Absolute paths and `..` are rejected, and canonicalized targets must stay
10//! inside their configured roots.
11
12use std::net::IpAddr;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::time::Duration;
16
17use anyhow::{anyhow, Context, Result};
18use reqwest::header::CONTENT_TYPE;
19use serde_json::{json, Value};
20
21const MAX_READ_BYTES: usize = 200_000;
22const MAX_SCHED_READ_BYTES: usize = 50_000;
23const MAX_GREP_MATCHES: usize = 200;
24const MAX_RG_OUTPUT_BYTES: usize = 200_000;
25const MAX_TOPOLOGY_OUTPUT_BYTES: usize = 100_000;
26const DEFAULT_WEB_FETCH_BYTES: usize = 50_000;
27const MAX_WEB_FETCH_BYTES: usize = 100_000;
28const LOCAL_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
29
30/// The function-calling tool schema advertised to the model.
31pub fn openai_tools_json(allow_edit: bool, allow_scheduler_refs: bool) -> Value {
32    let mut tools = vec![json!({
33        "type": "function",
34        "function": {
35            "name": "grep",
36            "description": "Search the scheduler crate with a regex and return file:line matches. Use this FIRST to locate a symbol before reading a file.",
37            "parameters": {
38                "type": "object",
39                "properties": {
40                    "pattern": {"type": "string", "description": "Rust regex to search for."},
41                    "glob": {"type": "string", "description": "Optional path substring filter, e.g. 'main.bpf.c'."}
42                },
43                "required": ["pattern"]
44            }
45        }
46    })];
47
48    if rg_available() {
49        tools.push(json!({
50            "type": "function",
51            "function": {
52                "name": "rg",
53                "description": "Fast ripgrep search inside the scheduler crate. Use this for broad searches when available; paths are crate-relative and output is file:line:match.",
54                "parameters": {
55                    "type": "object",
56                    "properties": {
57                        "pattern": {"type": "string", "description": "Ripgrep regex to search for."},
58                        "path": {"type": "string", "description": "Optional crate-relative file or directory to search, default '.'."},
59                        "glob": {"type": "string", "description": "Optional ripgrep glob filter, e.g. '*.rs' or 'src/bpf/**'."}
60                    },
61                    "required": ["pattern"]
62                }
63            }
64        }));
65    }
66
67    tools.extend([
68        json!({
69            "type": "function",
70            "function": {
71                "name": "read_file",
72                "description": "Read a file inside the scheduler crate. Paths are relative to the crate root. Prefer tight start_line/end_line bounds. Output is prefixed with line numbers and a tab ('   123\\tcode') for navigation only - those prefixes are NOT part of the file; never include them in edit_file's old_string/new_string.",
73                "parameters": {
74                    "type": "object",
75                    "properties": {
76                        "path": {"type": "string", "description": "Crate-relative path. Absolute paths and '..' are rejected."},
77                        "start_line": {"type": "integer", "description": "1-based first line (optional)."},
78                        "end_line": {"type": "integer", "description": "1-based last line (optional)."}
79                    },
80                    "required": ["path"]
81                }
82            }
83        }),
84        json!({
85            "type": "function",
86            "function": {
87                "name": "list_dir",
88                "description": "List files and subdirectories of a crate-relative directory ('' or '.' for the crate root).",
89                "parameters": {
90                    "type": "object",
91                    "properties": {
92                        "path": {"type": "string", "description": "Crate-relative directory (optional, defaults to crate root)."}
93                    }
94                }
95            }
96        }),
97        json!({
98            "type": "function",
99            "function": {
100                "name": "lscpu_e",
101                "description": "Run the fixed read-only host-topology command `lscpu -e` and return its output. Use this to inspect CPU, core, socket, node, cache, online, and maxmhz topology columns exposed by lscpu on this host.",
102                "parameters": {
103                    "type": "object",
104                    "properties": {}
105                }
106            }
107        }),
108        json!({
109            "type": "function",
110            "function": {
111                "name": "numactl_hardware",
112                "description": "Run the fixed read-only host-topology command `numactl -H` and return its output. Use this to inspect NUMA nodes, node CPU lists, memory sizes, and distance matrix for host-specific scheduling policy choices.",
113                "parameters": {
114                    "type": "object",
115                    "properties": {}
116                }
117            }
118        }),
119        json!({
120            "type": "function",
121            "function": {
122                "name": "cpu_cache_sizes",
123                "description": "Read fixed sysfs cache metadata from `/sys/devices/system/cpu/cpu*/cache/index*/size` and related read-only files. Output is tab-separated as cpu, cache index, level, type, size, and shared_cpu_list.",
124                "parameters": {
125                    "type": "object",
126                    "properties": {}
127                }
128            }
129        }),
130        json!({
131            "type": "function",
132            "function": {
133                "name": "web_fetch",
134                "description": "Fetch a public http(s) URL for scheduler theory, kernel scheduling papers, documentation, or algorithm ideas. This is not a search engine: pass a direct URL. Public text/HTML/JSON/XML content only; localhost, private networks, and binary content are rejected. Output is truncated.",
135                "parameters": {
136                    "type": "object",
137                    "properties": {
138                        "url": {"type": "string", "description": "Direct public http(s) URL to fetch."},
139                        "max_bytes": {"type": "integer", "description": "Optional output byte cap, default 50000, maximum 100000."}
140                    },
141                    "required": ["url"]
142                }
143            }
144        }),
145    ]);
146
147    if allow_scheduler_refs {
148        tools.extend([
149            json!({
150                "type": "function",
151                "function": {
152                    "name": "list_schedulers",
153                    "description": "List scheduler crate directories available for read-only comparison under scheds/rust. Note: scx_simple is not available in this repo.",
154                    "parameters": {
155                        "type": "object",
156                        "properties": {}
157                    }
158                }
159            }),
160            json!({
161                "type": "function",
162                "function": {
163                    "name": "grep_schedulers",
164                    "description": "Read-only search across scheduler crates under scheds/rust. Use this to find policy ideas in other schedulers before adapting them to the target crate. Note: scx_simple is not available in this repo.",
165                    "parameters": {
166                        "type": "object",
167                        "properties": {
168                            "pattern": {"type": "string", "description": "Rust regex to search for."},
169                            "scheduler": {"type": "string", "description": "Optional scheduler directory name under scheds/rust, e.g. scx_rusty. Omit to search all schedulers. Do not use scx_simple; it is not available in this repo."},
170                            "glob": {"type": "string", "description": "Optional path substring filter, e.g. 'src/bpf/main.bpf.c'."}
171                        },
172                        "required": ["pattern"]
173                    }
174                }
175            }),
176            json!({
177                "type": "function",
178                "function": {
179                    "name": "read_scheduler_file",
180                    "description": "Read a bounded file range from another scheduler crate under scheds/rust for comparison. This is read-only; use edit_file only on the target crate. For large files, call grep_schedulers first and then pass tight start_line/end_line bounds; unbounded large reads are refused to avoid context overflow. Note: scx_simple is not available in this repo.",
181                    "parameters": {
182                        "type": "object",
183                        "properties": {
184                            "scheduler": {"type": "string", "description": "Scheduler directory name under scheds/rust, e.g. scx_rusty. Do not use scx_simple; it is not available in this repo."},
185                            "path": {"type": "string", "description": "Path relative to that scheduler crate, e.g. src/bpf/main.bpf.c. Absolute paths and '..' are rejected."},
186                            "start_line": {"type": "integer", "description": "1-based first line (optional)."},
187                            "end_line": {"type": "integer", "description": "1-based last line (optional)."}
188                        },
189                        "required": ["scheduler", "path"]
190                    }
191                }
192            }),
193        ]);
194    }
195
196    if allow_edit {
197        tools.push(json!({
198            "type": "function",
199            "function": {
200                "name": "edit_file",
201                "description": "Replace an exact substring in a crate file. old_string must be the verbatim file text (NO read_file line-number/tab prefixes), occur exactly once unless replace_all=true, and differ from new_string.",
202                "parameters": {
203                    "type": "object",
204                    "properties": {
205                        "path": {"type": "string", "description": "Crate-relative path. Absolute paths and '..' are rejected."},
206                        "old_string": {"type": "string", "description": "Exact file text to replace, copied verbatim WITHOUT read_file's line-number+tab prefixes. Include surrounding context to make it unique."},
207                        "new_string": {"type": "string", "description": "Replacement text (also without any line-number prefixes)."},
208                        "replace_all": {"type": "boolean", "description": "Replace every occurrence (default false)."}
209                    },
210                    "required": ["path", "old_string", "new_string"]
211                }
212            }
213        }));
214    }
215
216    Value::Array(tools)
217}
218
219/// Resolve a crate-relative path inside `sandbox`, rejecting `..`/absolute and escapes.
220fn resolve(sandbox: &Path, relative: &str) -> Result<PathBuf> {
221    let relative = relative.trim();
222    if relative.contains("..") || relative.starts_with('/') {
223        anyhow::bail!("path must be crate-relative without '..': {relative:?}");
224    }
225    let rel = relative.trim_start_matches("./");
226    let full = if rel.is_empty() || rel == "." {
227        sandbox.to_path_buf()
228    } else {
229        sandbox.join(rel)
230    };
231    let canon_sandbox = sandbox
232        .canonicalize()
233        .with_context(|| format!("canonicalize sandbox {}", sandbox.display()))?;
234    // The target may not exist yet (it always does for our tools), so canonicalize
235    // the existing parent and re-append the final component when needed.
236    let canon_full = match full.canonicalize() {
237        Ok(p) => p,
238        Err(_) => full.clone(),
239    };
240    if !canon_full.starts_with(&canon_sandbox) {
241        anyhow::bail!("path escapes the crate sandbox: {relative:?}");
242    }
243    Ok(full)
244}
245
246fn rg_available() -> bool {
247    Command::new("rg")
248        .arg("--version")
249        .stdout(Stdio::null())
250        .stderr(Stdio::null())
251        .status()
252        .is_ok_and(|status| status.success())
253}
254
255fn normalized_crate_relative_path(path: &str) -> String {
256    let rel = path.trim().trim_start_matches("./");
257    if rel.is_empty() {
258        ".".to_string()
259    } else {
260        rel.to_string()
261    }
262}
263
264fn rg_glob_arg(args: &Value) -> Result<Option<String>> {
265    let Some(glob) = args.get("glob").and_then(|v| v.as_str()) else {
266        return Ok(None);
267    };
268    let glob = glob.trim();
269    if glob.is_empty() {
270        return Ok(None);
271    }
272    let body = glob.trim_start_matches('!');
273    if body.starts_with('/') || body.contains("..") {
274        anyhow::bail!("rg: glob must not be absolute or contain '..': {glob:?}");
275    }
276    Ok(Some(glob.to_string()))
277}
278
279fn truncate_lossy(bytes: &[u8], max_bytes: usize, marker: &str) -> String {
280    let mut out = String::from_utf8_lossy(&bytes[..bytes.len().min(max_bytes)]).to_string();
281    if bytes.len() > max_bytes {
282        out.push_str(marker);
283    }
284    out
285}
286
287fn run_fixed_topology_command(label: &str, program: &str, args: &[&str]) -> Result<String> {
288    let output = match Command::new(program).args(args).output() {
289        Ok(output) => output,
290        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
291            anyhow::bail!("{label}: command not found: {program}");
292        }
293        Err(e) => return Err(e).with_context(|| format!("{label}: execute {program}")),
294    };
295
296    let cmdline = std::iter::once(program)
297        .chain(args.iter().copied())
298        .collect::<Vec<_>>()
299        .join(" ");
300    if !output.status.success() {
301        let stderr = truncate_lossy(&output.stderr, 4000, "\n[... truncated ...]\n");
302        let stdout = truncate_lossy(&output.stdout, 4000, "\n[... truncated ...]\n");
303        anyhow::bail!(
304            "{label}: `{cmdline}` failed with status {}{}\n{}",
305            output.status,
306            if stderr.trim().is_empty() {
307                ""
308            } else {
309                "\nstderr:"
310            },
311            if stderr.trim().is_empty() {
312                stdout.trim()
313            } else {
314                stderr.trim()
315            }
316        );
317    }
318
319    let mut out = format!("command: {cmdline}\n\n");
320    let stdout = truncate_lossy(
321        &output.stdout,
322        MAX_TOPOLOGY_OUTPUT_BYTES,
323        "\n[... truncated by scx-forge-agent topology tool ...]\n",
324    );
325    if stdout.trim().is_empty() {
326        out.push_str("(no output)\n");
327    } else {
328        out.push_str(&stdout);
329    }
330    Ok(out)
331}
332
333fn lscpu_e() -> Result<String> {
334    run_fixed_topology_command("lscpu_e", "lscpu", &["-e"])
335}
336
337fn numactl_hardware() -> Result<String> {
338    run_fixed_topology_command("numactl_hardware", "numactl", &["-H"])
339}
340
341fn parse_prefixed_u32(name: &str, prefix: &str) -> Option<u32> {
342    name.strip_prefix(prefix)?.parse::<u32>().ok()
343}
344
345fn read_trimmed(path: &Path) -> Option<String> {
346    std::fs::read_to_string(path)
347        .ok()
348        .map(|s| s.trim().to_string())
349        .filter(|s| !s.is_empty())
350}
351
352fn cpu_cache_sizes_from_root(cpu_root: &Path) -> Result<String> {
353    let mut entries = Vec::new();
354
355    for cpu_ent in std::fs::read_dir(cpu_root)
356        .with_context(|| format!("cpu_cache_sizes: read {}", cpu_root.display()))?
357    {
358        let cpu_ent = cpu_ent?;
359        let cpu_name = cpu_ent.file_name().to_string_lossy().to_string();
360        let Some(cpu) = parse_prefixed_u32(&cpu_name, "cpu") else {
361            continue;
362        };
363
364        let cache_dir = cpu_ent.path().join("cache");
365        let Ok(cache_entries) = std::fs::read_dir(&cache_dir) else {
366            continue;
367        };
368
369        for index_ent in cache_entries.flatten() {
370            let index_name = index_ent.file_name().to_string_lossy().to_string();
371            let Some(index) = parse_prefixed_u32(&index_name, "index") else {
372                continue;
373            };
374
375            let index_dir = index_ent.path();
376            let Some(size) = read_trimmed(&index_dir.join("size")) else {
377                continue;
378            };
379            let level = read_trimmed(&index_dir.join("level")).unwrap_or_else(|| "?".to_string());
380            let ty = read_trimmed(&index_dir.join("type")).unwrap_or_else(|| "?".to_string());
381            let shared =
382                read_trimmed(&index_dir.join("shared_cpu_list")).unwrap_or_else(|| "?".to_string());
383
384            entries.push((cpu, index, level, ty, size, shared));
385        }
386    }
387
388    entries.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
389    if entries.is_empty() {
390        return Ok(format!(
391            "path: {}\n(no CPU cache size files found)\n",
392            cpu_root.display()
393        ));
394    }
395
396    let mut out = format!(
397        "path: {}\ncolumns: cpu\tindex\tlevel\ttype\tsize\tshared_cpu_list\n",
398        cpu_root.display()
399    );
400    for (cpu, index, level, ty, size, shared) in entries {
401        out.push_str(&format!(
402            "cpu{cpu}\tindex{index}\t{level}\t{ty}\t{size}\t{shared}\n"
403        ));
404        if out.len() > MAX_TOPOLOGY_OUTPUT_BYTES {
405            out.push_str("\n[... truncated by scx-forge-agent topology tool ...]\n");
406            break;
407        }
408    }
409    Ok(out)
410}
411
412fn cpu_cache_sizes() -> Result<String> {
413    cpu_cache_sizes_from_root(Path::new("/sys/devices/system/cpu"))
414}
415
416fn rg(sandbox: &Path, args: &Value) -> Result<String> {
417    let pattern = args
418        .get("pattern")
419        .and_then(|v| v.as_str())
420        .ok_or_else(|| anyhow!("rg: missing pattern"))?;
421    let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
422    let _ = resolve(sandbox, path_str)?;
423    let path_arg = normalized_crate_relative_path(path_str);
424    let glob = rg_glob_arg(args)?;
425
426    let mut cmd = Command::new("rg");
427    cmd.current_dir(sandbox)
428        .arg("--line-number")
429        .arg("--color=never")
430        .arg("--no-heading")
431        .arg("--hidden")
432        .arg("--no-ignore")
433        .arg("--max-columns")
434        .arg("500")
435        .arg("--max-columns-preview")
436        .arg("--max-count")
437        .arg(MAX_GREP_MATCHES.to_string())
438        .arg("--max-filesize")
439        .arg("1M")
440        .arg("--glob")
441        .arg("!.git/**")
442        .arg("--glob")
443        .arg("!target/**");
444    if let Some(glob) = glob {
445        cmd.arg("--glob").arg(glob);
446    }
447    cmd.arg("--").arg(pattern).arg(path_arg);
448
449    let output = match cmd.output() {
450        Ok(output) => output,
451        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452            anyhow::bail!("rg: ripgrep is not available on PATH");
453        }
454        Err(e) => return Err(e).context("rg: execute rg"),
455    };
456
457    match output.status.code() {
458        Some(0) => {
459            let out = truncate_lossy(
460                &output.stdout,
461                MAX_RG_OUTPUT_BYTES,
462                "\n[... truncated by scx-forge-agent rg ...]\n",
463            );
464            if out.trim().is_empty() {
465                Ok("(no matches)".to_string())
466            } else {
467                Ok(out)
468            }
469        }
470        Some(1) => Ok("(no matches)".to_string()),
471        _ => {
472            let err = truncate_lossy(&output.stderr, 4000, "\n[... truncated ...]\n");
473            let stdout = truncate_lossy(&output.stdout, 4000, "\n[... truncated ...]\n");
474            let detail = if err.trim().is_empty() { stdout } else { err };
475            anyhow::bail!("rg: {}", detail.trim());
476        }
477    }
478}
479
480fn read_file(sandbox: &Path, args: &Value) -> Result<String> {
481    let path_str = args
482        .get("path")
483        .and_then(|v| v.as_str())
484        .ok_or_else(|| anyhow!("read_file: missing path"))?;
485    let path = resolve(sandbox, path_str)?;
486    read_path(&path, args, "read_file", MAX_READ_BYTES, false)
487}
488
489fn read_path(
490    path: &Path,
491    args: &Value,
492    label: &str,
493    max_bytes: usize,
494    refuse_large_unbounded: bool,
495) -> Result<String> {
496    let content = std::fs::read_to_string(&path)
497        .with_context(|| format!("{label}: read {}", path.display()))?;
498    let lines: Vec<&str> = content.lines().collect();
499    let total = lines.len();
500
501    let start = args.get("start_line").and_then(|v| v.as_u64());
502    let end = args.get("end_line").and_then(|v| v.as_u64());
503    if refuse_large_unbounded && start.is_none() && end.is_none() && content.len() > max_bytes {
504        return Ok(format!(
505            "[{} is large: {} bytes, {} lines; full unbounded read suppressed by scx-forge-agent to avoid overflowing the model context. Use grep_schedulers to locate relevant symbols, then call read_scheduler_file with tight start_line/end_line bounds.]\n",
506            path.display(),
507            content.len(),
508            total
509        ));
510    }
511    let line_start_idx = |line: u64| {
512        usize::try_from(line.saturating_sub(1))
513            .unwrap_or(usize::MAX)
514            .min(total)
515    };
516    let line_end_idx = |line: u64| usize::try_from(line).unwrap_or(usize::MAX).min(total);
517    let (mut s, mut e) = match (start, end) {
518        (Some(s), Some(e)) if s >= 1 && e >= s => (line_start_idx(s), line_end_idx(e)),
519        (Some(s), None) if s >= 1 => (line_start_idx(s), total),
520        (None, Some(e)) if e >= 1 => (0, line_end_idx(e)),
521        _ => (0, total),
522    };
523    s = s.min(total);
524    e = e.min(total);
525    if e < s {
526        e = s;
527    }
528    let mut out = String::new();
529    if let Some(start) = start.filter(|line| usize::try_from(*line).map_or(true, |n| n > total)) {
530        out.push_str(&format!(
531            "[requested start_line {} is past EOF; file has {} lines]\n",
532            start, total
533        ));
534    }
535    for (i, line) in lines[s..e].iter().enumerate() {
536        out.push_str(&format!("{:>6}\t{}\n", s + i + 1, line));
537        if out.len() > max_bytes {
538            out.push_str("\n[... truncated by scx-forge-agent ...]\n");
539            break;
540        }
541    }
542    Ok(out)
543}
544
545fn scheduler_dir(scheds_root: &Path, scheduler: &str) -> Result<PathBuf> {
546    let scheduler = scheduler.trim();
547    if scheduler.is_empty()
548        || scheduler.contains('/')
549        || scheduler.contains('\\')
550        || scheduler.contains("..")
551        || scheduler.starts_with('.')
552    {
553        anyhow::bail!("scheduler must be a directory name under scheds/rust: {scheduler:?}");
554    }
555    if scheduler == "scx_simple" {
556        anyhow::bail!(
557            "scx_simple is not available in this repo; call list_schedulers and choose one of the reported scheduler crates"
558        );
559    }
560
561    let canon_root = scheds_root
562        .canonicalize()
563        .with_context(|| format!("canonicalize scheds root {}", scheds_root.display()))?;
564    let dir = scheds_root.join(scheduler);
565    let canon_dir = dir
566        .canonicalize()
567        .with_context(|| format!("scheduler not found under scheds/rust: {scheduler}"))?;
568    if !canon_dir.starts_with(&canon_root) || !canon_dir.is_dir() {
569        anyhow::bail!("scheduler path escapes scheds/rust: {scheduler:?}");
570    }
571    Ok(canon_dir)
572}
573
574fn resolve_scheduler_path(scheds_root: &Path, scheduler: &str, relative: &str) -> Result<PathBuf> {
575    let dir = scheduler_dir(scheds_root, scheduler)?;
576    resolve(&dir, relative)
577}
578
579fn list_schedulers(scheds_root: &Path) -> Result<String> {
580    let mut entries = Vec::new();
581    for ent in std::fs::read_dir(scheds_root)
582        .with_context(|| format!("list_schedulers: {}", scheds_root.display()))?
583    {
584        let ent = ent?;
585        if ent.file_type().map(|t| t.is_dir()).unwrap_or(false) {
586            entries.push(ent.file_name().to_string_lossy().to_string());
587        }
588    }
589    entries.sort();
590    Ok(entries.join("\n"))
591}
592
593fn read_scheduler_file(scheds_root: &Path, args: &Value) -> Result<String> {
594    let scheduler = args
595        .get("scheduler")
596        .and_then(|v| v.as_str())
597        .ok_or_else(|| anyhow!("read_scheduler_file: missing scheduler"))?;
598    let path_str = args
599        .get("path")
600        .and_then(|v| v.as_str())
601        .ok_or_else(|| anyhow!("read_scheduler_file: missing path"))?;
602    let path = resolve_scheduler_path(scheds_root, scheduler, path_str)?;
603    let content = read_path(
604        &path,
605        args,
606        "read_scheduler_file",
607        MAX_SCHED_READ_BYTES,
608        true,
609    )?;
610    Ok(format!(
611        "scheduler: {}\nfile: {}\n{}",
612        scheduler.trim(),
613        path_str.trim(),
614        content
615    ))
616}
617
618fn list_dir(sandbox: &Path, args: &Value) -> Result<String> {
619    let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
620    let dir = resolve(sandbox, path_str)?;
621    let mut entries: Vec<String> = Vec::new();
622    for ent in std::fs::read_dir(&dir).with_context(|| format!("list_dir: {}", dir.display()))? {
623        let ent = ent?;
624        let name = ent.file_name().to_string_lossy().to_string();
625        if ent.file_type().map(|t| t.is_dir()).unwrap_or(false) {
626            entries.push(format!("{name}/"));
627        } else {
628            entries.push(name);
629        }
630    }
631    entries.sort();
632    Ok(entries.join("\n"))
633}
634
635fn grep(sandbox: &Path, args: &Value) -> Result<String> {
636    let pattern = args
637        .get("pattern")
638        .and_then(|v| v.as_str())
639        .ok_or_else(|| anyhow!("grep: missing pattern"))?;
640    let glob = args.get("glob").and_then(|v| v.as_str());
641    let re =
642        regex::Regex::new(pattern).with_context(|| format!("grep: invalid regex {pattern:?}"))?;
643
644    let mut matches: Vec<String> = Vec::new();
645    grep_dir(sandbox, None, glob, &re, &mut matches);
646    if matches.is_empty() {
647        Ok("(no matches)".to_string())
648    } else {
649        Ok(matches.join("\n"))
650    }
651}
652
653fn grep_dir(
654    root: &Path,
655    prefix: Option<&str>,
656    glob: Option<&str>,
657    re: &regex::Regex,
658    matches: &mut Vec<String>,
659) {
660    let mut stack = vec![root.to_path_buf()];
661    while let Some(dir) = stack.pop() {
662        let rd = match std::fs::read_dir(&dir) {
663            Ok(rd) => rd,
664            Err(_) => continue,
665        };
666        for ent in rd.flatten() {
667            let p = ent.path();
668            let ft = match ent.file_type() {
669                Ok(ft) => ft,
670                Err(_) => continue,
671            };
672            if ft.is_dir() {
673                let name = ent.file_name().to_string_lossy().to_string();
674                if name == "target" || name == ".git" {
675                    continue;
676                }
677                stack.push(p);
678                continue;
679            }
680            let rel = p
681                .strip_prefix(root)
682                .unwrap_or(&p)
683                .to_string_lossy()
684                .to_string();
685            if let Some(g) = glob {
686                if !rel.contains(g) {
687                    continue;
688                }
689            }
690            let shown = match prefix {
691                Some(prefix) => format!("{prefix}/{rel}"),
692                None => rel,
693            };
694            let content = match std::fs::read_to_string(&p) {
695                Ok(c) => c,
696                Err(_) => continue,
697            };
698            for (i, line) in content.lines().enumerate() {
699                if re.is_match(line) {
700                    matches.push(format!("{}:{}:{}", shown, i + 1, line.trim_end()));
701                    if matches.len() >= MAX_GREP_MATCHES {
702                        matches.push("[... more matches truncated ...]".to_string());
703                        return;
704                    }
705                }
706            }
707        }
708    }
709}
710
711fn grep_schedulers(scheds_root: &Path, args: &Value) -> Result<String> {
712    let pattern = args
713        .get("pattern")
714        .and_then(|v| v.as_str())
715        .ok_or_else(|| anyhow!("grep_schedulers: missing pattern"))?;
716    let glob = args.get("glob").and_then(|v| v.as_str());
717    let re = regex::Regex::new(pattern)
718        .with_context(|| format!("grep_schedulers: invalid regex {pattern:?}"))?;
719
720    let mut matches = Vec::new();
721    if let Some(scheduler) = args.get("scheduler").and_then(|v| v.as_str()) {
722        let dir = scheduler_dir(scheds_root, scheduler)?;
723        grep_dir(&dir, Some(scheduler.trim()), glob, &re, &mut matches);
724    } else {
725        let mut schedulers: Vec<String> = std::fs::read_dir(scheds_root)
726            .with_context(|| format!("grep_schedulers: {}", scheds_root.display()))?
727            .flatten()
728            .filter_map(|ent| {
729                ent.file_type()
730                    .ok()
731                    .filter(|t| t.is_dir())
732                    .map(|_| ent.file_name().to_string_lossy().to_string())
733            })
734            .collect();
735        schedulers.sort();
736        for scheduler in schedulers {
737            let dir = match scheduler_dir(scheds_root, &scheduler) {
738                Ok(dir) => dir,
739                Err(_) => continue,
740            };
741            grep_dir(&dir, Some(&scheduler), glob, &re, &mut matches);
742            if matches.len() >= MAX_GREP_MATCHES {
743                break;
744            }
745        }
746    }
747
748    if matches.is_empty() {
749        Ok("(no matches)".to_string())
750    } else {
751        Ok(matches.join("\n"))
752    }
753}
754
755fn web_fetch_max_bytes(args: &Value) -> usize {
756    args.get("max_bytes")
757        .and_then(|v| v.as_u64())
758        .and_then(|v| usize::try_from(v).ok())
759        .filter(|&v| v > 0)
760        .unwrap_or(DEFAULT_WEB_FETCH_BYTES)
761        .min(MAX_WEB_FETCH_BYTES)
762}
763
764fn is_disallowed_host(host: &str) -> bool {
765    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
766    host.is_empty()
767        || host == "localhost"
768        || host.ends_with(".localhost")
769        || host.ends_with(".local")
770}
771
772fn is_disallowed_ip(ip: IpAddr) -> bool {
773    match ip {
774        IpAddr::V4(ip) => {
775            let o = ip.octets();
776            ip.is_loopback()
777                || ip.is_private()
778                || ip.is_link_local()
779                || ip.is_broadcast()
780                || ip.is_unspecified()
781                || o[0] == 0
782                || (o[0] == 100 && (64..=127).contains(&o[1]))
783                || (o[0] == 169 && o[1] == 254)
784                || (o[0] == 198 && (18..=19).contains(&o[1]))
785        }
786        IpAddr::V6(ip) => {
787            let s = ip.segments();
788            ip.is_loopback()
789                || ip.is_unspecified()
790                || (s[0] & 0xfe00) == 0xfc00
791                || (s[0] & 0xffc0) == 0xfe80
792        }
793    }
794}
795
796async fn validate_public_url(url: &reqwest::Url) -> Result<()> {
797    match url.scheme() {
798        "http" | "https" => {}
799        scheme => anyhow::bail!("web_fetch: unsupported URL scheme {scheme:?}; use http(s)"),
800    }
801    if !url.username().is_empty() || url.password().is_some() {
802        anyhow::bail!("web_fetch: credentials in URLs are not allowed");
803    }
804
805    let host = url
806        .host_str()
807        .ok_or_else(|| anyhow!("web_fetch: URL is missing host"))?;
808    if is_disallowed_host(host) {
809        anyhow::bail!("web_fetch: refusing localhost/private host {host:?}");
810    }
811    if let Ok(ip) = host.parse::<IpAddr>() {
812        if is_disallowed_ip(ip) {
813            anyhow::bail!("web_fetch: refusing private or local IP address {ip}");
814        }
815        return Ok(());
816    }
817
818    let port = url
819        .port_or_known_default()
820        .ok_or_else(|| anyhow!("web_fetch: URL has no known port"))?;
821    let addrs = tokio::net::lookup_host((host, port))
822        .await
823        .with_context(|| format!("web_fetch: DNS lookup failed for {host}"))?;
824    for addr in addrs {
825        let ip = addr.ip();
826        if is_disallowed_ip(ip) {
827            anyhow::bail!(
828                "web_fetch: refusing host {host:?}; DNS resolved to private or local IP {ip}"
829            );
830        }
831    }
832
833    Ok(())
834}
835
836fn web_fetch_content_type_allowed(content_type: Option<&str>) -> bool {
837    let Some(content_type) = content_type else {
838        return true;
839    };
840    let ct = content_type
841        .split(';')
842        .next()
843        .unwrap_or(content_type)
844        .trim()
845        .to_ascii_lowercase();
846    ct.starts_with("text/")
847        || matches!(
848            ct.as_str(),
849            "application/json"
850                | "application/ld+json"
851                | "application/xml"
852                | "application/xhtml+xml"
853                | "application/rss+xml"
854                | "application/atom+xml"
855                | "application/x-bibtex"
856        )
857}
858
859fn decode_basic_entities(s: &str) -> String {
860    s.replace("&nbsp;", " ")
861        .replace("&amp;", "&")
862        .replace("&lt;", "<")
863        .replace("&gt;", ">")
864        .replace("&quot;", "\"")
865        .replace("&#39;", "'")
866        .replace("&apos;", "'")
867}
868
869fn collapse_text(s: &str) -> String {
870    let mut out = String::new();
871    let mut blank_lines = 0usize;
872    for line in s.lines() {
873        let trimmed = line.split_whitespace().collect::<Vec<_>>().join(" ");
874        if trimmed.is_empty() {
875            blank_lines += 1;
876            if blank_lines <= 1 && !out.ends_with('\n') {
877                out.push('\n');
878            }
879            continue;
880        }
881        blank_lines = 0;
882        out.push_str(&trimmed);
883        out.push('\n');
884    }
885    out.trim().to_string()
886}
887
888fn html_to_text(html: &str) -> String {
889    let mut s = html.to_string();
890    for pat in [
891        r"(?is)<script\b[^>]*>.*?</script>",
892        r"(?is)<style\b[^>]*>.*?</style>",
893        r"(?is)<noscript\b[^>]*>.*?</noscript>",
894        r"(?is)<!--.*?-->",
895    ] {
896        if let Ok(re) = regex::Regex::new(pat) {
897            s = re.replace_all(&s, " ").to_string();
898        }
899    }
900    if let Ok(re) = regex::Regex::new(r"(?is)<\s*(br|/p|/div|/li|/h[1-6]|/tr)\b[^>]*>") {
901        s = re.replace_all(&s, "\n").to_string();
902    }
903    if let Ok(re) = regex::Regex::new(r"(?is)<[^>]+>") {
904        s = re.replace_all(&s, " ").to_string();
905    }
906    collapse_text(&decode_basic_entities(&s))
907}
908
909async fn web_fetch(args: &Value) -> Result<String> {
910    let url_str = args
911        .get("url")
912        .and_then(|v| v.as_str())
913        .ok_or_else(|| anyhow!("web_fetch: missing url"))?;
914    let url = reqwest::Url::parse(url_str).with_context(|| "web_fetch: invalid URL")?;
915    validate_public_url(&url).await?;
916
917    let max_bytes = web_fetch_max_bytes(args);
918    let client = reqwest::Client::builder()
919        .connect_timeout(Duration::from_secs(10))
920        .timeout(Duration::from_secs(30))
921        .redirect(reqwest::redirect::Policy::limited(5))
922        .user_agent(concat!(
923            "scx-forge-agent-web-fetch/",
924            env!("CARGO_PKG_VERSION")
925        ))
926        .build()
927        .context("web_fetch: build HTTP client")?;
928
929    let mut resp = client
930        .get(url)
931        .send()
932        .await
933        .context("web_fetch: request failed")?;
934    let status = resp.status();
935    let final_url = resp.url().clone();
936    if !status.is_success() {
937        anyhow::bail!("web_fetch: HTTP status {status} for {final_url}");
938    }
939
940    let content_type = resp
941        .headers()
942        .get(CONTENT_TYPE)
943        .and_then(|v| v.to_str().ok())
944        .map(str::to_string);
945    if !web_fetch_content_type_allowed(content_type.as_deref()) {
946        anyhow::bail!(
947            "web_fetch: unsupported content-type {:?}; fetch public text/html/json/xml pages",
948            content_type
949        );
950    }
951
952    let mut body = Vec::new();
953    let mut truncated = false;
954    while let Some(chunk) = resp
955        .chunk()
956        .await
957        .context("web_fetch: read response body")?
958    {
959        if body.len() + chunk.len() > max_bytes {
960            let take = max_bytes.saturating_sub(body.len());
961            body.extend_from_slice(&chunk[..take]);
962            truncated = true;
963            break;
964        }
965        body.extend_from_slice(&chunk);
966    }
967
968    let raw = String::from_utf8_lossy(&body);
969    let ct = content_type.as_deref().unwrap_or("");
970    let text = if ct.to_ascii_lowercase().contains("html") || raw.contains("<html") {
971        html_to_text(&raw)
972    } else {
973        collapse_text(&decode_basic_entities(&raw))
974    };
975    let mut out = format!(
976        "url: {final_url}\nstatus: {status}\ncontent-type: {}\nbytes-read: {}{}\n\n",
977        content_type.as_deref().unwrap_or("(not provided)"),
978        body.len(),
979        if truncated { " (truncated)" } else { "" }
980    );
981    out.push_str(&text);
982    if truncated {
983        out.push_str("\n\n[... truncated by scx-forge-agent web_fetch ...]");
984    }
985    Ok(out)
986}
987
988fn count_occurrences(haystack: &str, needle: &str) -> usize {
989    if needle.is_empty() {
990        return 0;
991    }
992    let mut idx = 0;
993    let mut n = 0;
994    while let Some(pos) = haystack[idx..].find(needle) {
995        n += 1;
996        idx += pos + needle.len();
997    }
998    n
999}
1000
1001fn edit_file(sandbox: &Path, args: &Value) -> Result<String> {
1002    let path_str = args
1003        .get("path")
1004        .and_then(|v| v.as_str())
1005        .ok_or_else(|| anyhow!("edit_file: missing path"))?;
1006    let old = args
1007        .get("old_string")
1008        .and_then(|v| v.as_str())
1009        .ok_or_else(|| anyhow!("edit_file: missing old_string"))?;
1010    let new = args
1011        .get("new_string")
1012        .and_then(|v| v.as_str())
1013        .ok_or_else(|| anyhow!("edit_file: missing new_string"))?;
1014    let replace_all = args
1015        .get("replace_all")
1016        .and_then(|v| v.as_bool())
1017        .unwrap_or(false);
1018
1019    if old.is_empty() {
1020        anyhow::bail!("edit_file: old_string must not be empty");
1021    }
1022    if old == new {
1023        anyhow::bail!("edit_file: new_string must differ from old_string");
1024    }
1025    let path = resolve(sandbox, path_str)?;
1026    if !path.is_file() {
1027        anyhow::bail!("edit_file: {path_str} is not a regular file");
1028    }
1029    let content = std::fs::read_to_string(&path)
1030        .with_context(|| format!("edit_file: read {}", path.display()))?;
1031
1032    // 1. Exact match, then 2. with read_file's line-number+tab prefixes stripped
1033    // ("   123\t..."): models frequently copy old_string straight from read_file
1034    // output, prefixes and all, which never matches the raw file bytes.
1035    for (i, cand) in [old.to_string(), strip_line_number_prefixes(old)]
1036        .iter()
1037        .enumerate()
1038    {
1039        let occ = count_occurrences(&content, cand);
1040        if occ == 0 {
1041            continue;
1042        }
1043        if occ > 1 && !replace_all {
1044            anyhow::bail!(
1045                "edit_file: old_string occurs {occ} times in {path_str}; pass replace_all=true or extend old_string with more context to make it unique"
1046            );
1047        }
1048        let repl = if i == 0 {
1049            new.to_string()
1050        } else {
1051            strip_line_number_prefixes(new)
1052        };
1053        let updated = if replace_all {
1054            content.replace(cand, &repl)
1055        } else {
1056            content.replacen(cand, &repl, 1)
1057        };
1058        std::fs::write(&path, updated)
1059            .with_context(|| format!("edit_file: write {}", path.display()))?;
1060        return Ok(format!("edited {path_str} ({occ} occurrence(s) replaced)"));
1061    }
1062
1063    // 3. Whitespace-tolerant fallback: match a unique block of lines ignoring
1064    // per-line indentation and surrounding blank lines (the usual reason a weak
1065    // model's old_string fails to match), then replace that real region.
1066    let stripped_old = strip_line_number_prefixes(old);
1067    if let Some((bs, be)) = fuzzy_line_match(&content, &stripped_old) {
1068        let mut repl = strip_line_number_prefixes(new);
1069        if content[bs..be].ends_with('\n') && !repl.ends_with('\n') {
1070            repl.push('\n');
1071        }
1072        let updated = format!("{}{}{}", &content[..bs], repl, &content[be..]);
1073        std::fs::write(&path, updated)
1074            .with_context(|| format!("edit_file: write {}", path.display()))?;
1075        return Ok(format!(
1076            "edited {path_str} (whitespace-tolerant match; indentation taken from new_string)"
1077        ));
1078    }
1079
1080    // 4. Give up, but point the model at the closest real lines so it can fix
1081    // old_string on the next attempt.
1082    let anchor = stripped_old
1083        .lines()
1084        .map(str::trim)
1085        .find(|l| !l.is_empty())
1086        .unwrap_or("");
1087    let hint = nearest_lines(&content, anchor);
1088    let hint_msg = if hint.is_empty() {
1089        String::new()
1090    } else {
1091        format!("\nClosest lines currently in the file:\n{hint}")
1092    };
1093    anyhow::bail!(
1094        "edit_file: old_string not found in {path_str}. Provide the exact file text verbatim \
1095         (no line-number prefixes); indentation may differ but the lines must otherwise match.{hint_msg}"
1096    );
1097}
1098
1099/// Find a unique block of lines in `content` whose whitespace-trimmed text
1100/// equals the trimmed lines of `needle` (ignoring indentation and blank lines
1101/// around the block). Returns the byte range [start, end) of that block, or
1102/// None if there is not exactly one match.
1103fn fuzzy_line_match(content: &str, needle: &str) -> Option<(usize, usize)> {
1104    let needle_trim: Vec<&str> = needle.lines().map(str::trim).collect();
1105    let first = needle_trim.iter().position(|l| !l.is_empty())?;
1106    let last = needle_trim.iter().rposition(|l| !l.is_empty())?;
1107    let core = &needle_trim[first..=last];
1108    let w = core.len();
1109
1110    let file_lines: Vec<&str> = content.split_inclusive('\n').collect();
1111    if file_lines.len() < w {
1112        return None;
1113    }
1114    let file_trim: Vec<&str> = file_lines.iter().map(|l| l.trim()).collect();
1115    let mut offsets = Vec::with_capacity(file_lines.len() + 1);
1116    let mut acc = 0usize;
1117    for l in &file_lines {
1118        offsets.push(acc);
1119        acc += l.len();
1120    }
1121    offsets.push(acc);
1122
1123    let mut found: Option<usize> = None;
1124    for s in 0..=(file_trim.len() - w) {
1125        if file_trim[s..s + w] == core[..] {
1126            if found.is_some() {
1127                return None; // ambiguous - refuse rather than edit the wrong block
1128            }
1129            found = Some(s);
1130        }
1131    }
1132    let s = found?;
1133    Some((offsets[s], offsets[s + w]))
1134}
1135
1136/// Up to 5 file lines (with 1-based line numbers) that contain `anchor`, for
1137/// error feedback when old_string doesn't match.
1138fn nearest_lines(content: &str, anchor: &str) -> String {
1139    let anchor = anchor.trim();
1140    if anchor.is_empty() {
1141        return String::new();
1142    }
1143    content
1144        .lines()
1145        .enumerate()
1146        .filter(|(_, l)| l.contains(anchor))
1147        .take(5)
1148        .map(|(i, l)| format!("{}\t{}", i + 1, l))
1149        .collect::<Vec<_>>()
1150        .join("\n")
1151}
1152
1153/// Strip a leading line-number+tab prefix (as emitted by read_file's
1154/// `"{:>6}\t"` format) from each line. Preserves a trailing newline.
1155fn strip_line_number_prefixes(s: &str) -> String {
1156    fn strip(line: &str) -> &str {
1157        let bytes = line.as_bytes();
1158        let mut i = 0;
1159        while i < bytes.len() && bytes[i] == b' ' {
1160            i += 1;
1161        }
1162        let digit_start = i;
1163        while i < bytes.len() && bytes[i].is_ascii_digit() {
1164            i += 1;
1165        }
1166        // Require at least one digit followed by a tab to treat it as a prefix.
1167        if i > digit_start && i < bytes.len() && bytes[i] == b'\t' {
1168            &line[i + 1..]
1169        } else {
1170            line
1171        }
1172    }
1173    let joined = s.lines().map(strip).collect::<Vec<_>>().join("\n");
1174    if s.ends_with('\n') {
1175        format!("{joined}\n")
1176    } else {
1177        joined
1178    }
1179}
1180
1181/// True for write-capable tools (used by the controller to count edits per round).
1182pub fn is_write_tool(name: &str) -> bool {
1183    name == "edit_file"
1184}
1185
1186/// Dispatch a tool call. `allow_edit` gates `edit_file`.
1187pub fn execute_tool(
1188    sandbox: &Path,
1189    scheds_root: Option<&Path>,
1190    name: &str,
1191    args_json: &str,
1192    allow_edit: bool,
1193) -> Result<String> {
1194    let args: Value = serde_json::from_str(args_json)
1195        .with_context(|| format!("tool {name}: arguments are not valid JSON: {args_json}"))?;
1196    match name {
1197        "grep" => grep(sandbox, &args),
1198        "rg" => rg(sandbox, &args),
1199        "read_file" => read_file(sandbox, &args),
1200        "list_dir" => list_dir(sandbox, &args),
1201        "lscpu_e" => lscpu_e(),
1202        "numactl_hardware" => numactl_hardware(),
1203        "cpu_cache_sizes" => cpu_cache_sizes(),
1204        "list_schedulers" => list_schedulers(
1205            scheds_root.ok_or_else(|| anyhow!("list_schedulers is not configured"))?,
1206        ),
1207        "grep_schedulers" => grep_schedulers(
1208            scheds_root.ok_or_else(|| anyhow!("grep_schedulers is not configured"))?,
1209            &args,
1210        ),
1211        "read_scheduler_file" => read_scheduler_file(
1212            scheds_root.ok_or_else(|| anyhow!("read_scheduler_file is not configured"))?,
1213            &args,
1214        ),
1215        "edit_file" => {
1216            if !allow_edit {
1217                anyhow::bail!("edit_file is disabled in this run");
1218            }
1219            edit_file(sandbox, &args)
1220        }
1221        other => anyhow::bail!("unknown tool: {other}"),
1222    }
1223}
1224
1225/// Async dispatch for tools that may perform network I/O.
1226pub async fn execute_tool_async(
1227    sandbox: &Path,
1228    scheds_root: Option<&Path>,
1229    name: &str,
1230    args_json: &str,
1231    allow_edit: bool,
1232) -> Result<String> {
1233    if name == "web_fetch" {
1234        let args: Value = serde_json::from_str(args_json)
1235            .with_context(|| format!("tool {name}: arguments are not valid JSON: {args_json}"))?;
1236        web_fetch(&args).await
1237    } else {
1238        let sandbox = sandbox.to_path_buf();
1239        let scheds_root = scheds_root.map(Path::to_path_buf);
1240        let name = name.to_string();
1241        let args_json = args_json.to_string();
1242        let timeout_name = name.clone();
1243        let handle = tokio::task::spawn_blocking(move || {
1244            execute_tool(
1245                &sandbox,
1246                scheds_root.as_deref(),
1247                &name,
1248                &args_json,
1249                allow_edit,
1250            )
1251        });
1252        match tokio::time::timeout(LOCAL_TOOL_TIMEOUT, handle).await {
1253            Ok(joined) => joined.context("local tool worker panicked")?,
1254            Err(_) => anyhow::bail!(
1255                "tool {timeout_name} timed out after {}s",
1256                LOCAL_TOOL_TIMEOUT.as_secs()
1257            ),
1258        }
1259    }
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264    use super::*;
1265
1266    fn tool_names(tools: Value) -> Vec<String> {
1267        tools
1268            .as_array()
1269            .unwrap()
1270            .iter()
1271            .filter_map(|tool| {
1272                tool.get("function")
1273                    .and_then(|f| f.get("name"))
1274                    .and_then(|name| name.as_str())
1275                    .map(str::to_string)
1276            })
1277            .collect()
1278    }
1279
1280    #[test]
1281    fn strips_read_file_prefixes() {
1282        // Mirrors read_file's "{:>6}\t" formatting.
1283        let prefixed = "   123\tlet x = 1;\n   124\tlet y = 2;";
1284        assert_eq!(
1285            strip_line_number_prefixes(prefixed),
1286            "let x = 1;\nlet y = 2;"
1287        );
1288    }
1289
1290    #[test]
1291    fn preserves_lines_without_prefix_and_trailing_newline() {
1292        assert_eq!(strip_line_number_prefixes("plain code\n"), "plain code\n");
1293        // A leading number that is NOT followed by a tab is left intact.
1294        assert_eq!(
1295            strip_line_number_prefixes("42 is the answer"),
1296            "42 is the answer"
1297        );
1298    }
1299
1300    #[test]
1301    fn read_file_past_eof_returns_note_instead_of_panicking() {
1302        let dir = std::env::temp_dir().join(format!("scx_read_eof_test_{}", std::process::id()));
1303        std::fs::create_dir_all(&dir).unwrap();
1304        let file = dir.join("f.txt");
1305        std::fs::write(&file, "one\ntwo\n").unwrap();
1306
1307        let args = json!({
1308            "path": "f.txt",
1309            "start_line": 10,
1310            "end_line": 12,
1311        });
1312        let out = read_file(&dir, &args).unwrap();
1313        assert!(
1314            out.contains("requested start_line 10 is past EOF"),
1315            "got: {out}"
1316        );
1317        assert!(out.contains("file has 2 lines"), "got: {out}");
1318
1319        let _ = std::fs::remove_dir_all(&dir);
1320    }
1321
1322    #[test]
1323    fn edit_file_recovers_from_copied_prefixes() {
1324        let dir = std::env::temp_dir().join(format!("scx_edit_test_{}", std::process::id()));
1325        std::fs::create_dir_all(&dir).unwrap();
1326        let file = dir.join("f.txt");
1327        std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap();
1328        // old_string carries read_file-style prefixes; new_string too.
1329        let args = json!({
1330            "path": "f.txt",
1331            "old_string": "     2\tbeta",
1332            "new_string": "     2\tBETA",
1333        });
1334        let out = edit_file(&dir, &args).unwrap();
1335        assert!(out.contains("edited"));
1336        assert_eq!(
1337            std::fs::read_to_string(&file).unwrap(),
1338            "alpha\nBETA\ngamma\n"
1339        );
1340        let _ = std::fs::remove_dir_all(&dir);
1341    }
1342
1343    #[test]
1344    fn fuzzy_line_match_ignores_indentation() {
1345        let content = "a\n    b\n    c\nd\n";
1346        let (s, e) = fuzzy_line_match(content, "b\nc").unwrap();
1347        assert_eq!(&content[s..e], "    b\n    c\n");
1348    }
1349
1350    #[test]
1351    fn fuzzy_line_match_refuses_ambiguous() {
1352        let content = "x\n    foo\nx\n    foo\n";
1353        assert!(fuzzy_line_match(content, "foo").is_none());
1354    }
1355
1356    #[test]
1357    fn edit_file_whitespace_tolerant() {
1358        let dir = std::env::temp_dir().join(format!("scx_fuzzy_test_{}", std::process::id()));
1359        std::fs::create_dir_all(&dir).unwrap();
1360        let file = dir.join("g.txt");
1361        std::fs::write(&file, "fn main() {\n    let x = 1;\n    let y = 2;\n}\n").unwrap();
1362        // old_string has the right lines but the wrong (missing) indentation.
1363        let args = json!({
1364            "path": "g.txt",
1365            "old_string": "let x = 1;\nlet y = 2;",
1366            "new_string": "let z = 3;",
1367        });
1368        let out = edit_file(&dir, &args).unwrap();
1369        assert!(out.contains("whitespace-tolerant"), "got: {out}");
1370        assert_eq!(
1371            std::fs::read_to_string(&file).unwrap(),
1372            "fn main() {\nlet z = 3;\n}\n"
1373        );
1374        let _ = std::fs::remove_dir_all(&dir);
1375    }
1376
1377    #[test]
1378    fn nearest_lines_points_at_match() {
1379        let content = "one\n    target_symbol = 5;\nthree\n";
1380        let hint = nearest_lines(content, "target_symbol = 5;");
1381        assert!(hint.contains("2\t"), "got: {hint}");
1382        assert!(hint.contains("target_symbol"));
1383    }
1384
1385    #[test]
1386    fn rg_schema_tracks_binary_availability() {
1387        let names = tool_names(openai_tools_json(false, true));
1388        assert_eq!(names.iter().any(|name| name == "rg"), rg_available());
1389    }
1390
1391    #[test]
1392    fn topology_tools_are_exposed() {
1393        let names = tool_names(openai_tools_json(false, true));
1394        assert!(names.iter().any(|name| name == "lscpu_e"));
1395        assert!(names.iter().any(|name| name == "numactl_hardware"));
1396        assert!(names.iter().any(|name| name == "cpu_cache_sizes"));
1397    }
1398
1399    #[test]
1400    fn cpu_cache_sizes_reads_fixed_sysfs_shape() {
1401        let base = std::env::temp_dir().join(format!("scx_cpu_cache_test_{}", std::process::id()));
1402        let index0 = base.join("cpu0/cache/index0");
1403        let index1 = base.join("cpu0/cache/index1");
1404        let index2 = base.join("cpu1/cache/index2");
1405        std::fs::create_dir_all(&index0).unwrap();
1406        std::fs::create_dir_all(&index1).unwrap();
1407        std::fs::create_dir_all(&index2).unwrap();
1408        std::fs::write(index0.join("size"), "32K\n").unwrap();
1409        std::fs::write(index0.join("level"), "1\n").unwrap();
1410        std::fs::write(index0.join("type"), "Data\n").unwrap();
1411        std::fs::write(index0.join("shared_cpu_list"), "0\n").unwrap();
1412        std::fs::write(index1.join("level"), "1\n").unwrap(); // no size: skipped
1413        std::fs::write(index2.join("size"), "1024K\n").unwrap();
1414        std::fs::write(index2.join("level"), "2\n").unwrap();
1415        std::fs::write(index2.join("type"), "Unified\n").unwrap();
1416        std::fs::write(index2.join("shared_cpu_list"), "0-1\n").unwrap();
1417
1418        let out = cpu_cache_sizes_from_root(&base).unwrap();
1419
1420        assert!(out.contains("columns: cpu\tindex\tlevel\ttype\tsize\tshared_cpu_list"));
1421        assert!(out.contains("cpu0\tindex0\t1\tData\t32K\t0"));
1422        assert!(out.contains("cpu1\tindex2\t2\tUnified\t1024K\t0-1"));
1423        assert!(!out.contains("index1"));
1424
1425        let _ = std::fs::remove_dir_all(&base);
1426    }
1427
1428    #[test]
1429    fn rg_rejects_path_escape() {
1430        let dir = std::env::temp_dir().join(format!("scx_rg_escape_test_{}", std::process::id()));
1431        std::fs::create_dir_all(&dir).unwrap();
1432        let args = json!({
1433            "pattern": "anything",
1434            "path": "../outside",
1435        });
1436
1437        let err = execute_tool(&dir, None, "rg", &args.to_string(), false)
1438            .unwrap_err()
1439            .to_string();
1440        assert!(err.contains("path must be crate-relative"), "got: {err}");
1441        let _ = std::fs::remove_dir_all(&dir);
1442    }
1443
1444    #[test]
1445    fn rg_searches_target_crate_when_available() {
1446        if !rg_available() {
1447            return;
1448        }
1449
1450        let dir = std::env::temp_dir().join(format!("scx_rg_test_{}", std::process::id()));
1451        std::fs::create_dir_all(dir.join("src/bpf")).unwrap();
1452        std::fs::create_dir_all(dir.join("target")).unwrap();
1453        std::fs::write(
1454            dir.join("src/bpf/main.bpf.c"),
1455            "void target_symbol(void) {}\n",
1456        )
1457        .unwrap();
1458        std::fs::write(dir.join("target/generated.c"), "target_symbol();\n").unwrap();
1459
1460        let args = json!({
1461            "pattern": "target_symbol",
1462            "path": "src",
1463            "glob": "*.bpf.c",
1464        });
1465        let out = execute_tool(&dir, None, "rg", &args.to_string(), false).unwrap();
1466
1467        assert!(
1468            out.contains("src/bpf/main.bpf.c:1:void target_symbol"),
1469            "got: {out}"
1470        );
1471        assert!(!out.contains("target/generated.c"), "got: {out}");
1472        let _ = std::fs::remove_dir_all(&dir);
1473    }
1474
1475    #[test]
1476    fn scheduler_reference_tools_are_read_only() {
1477        let base = std::env::temp_dir().join(format!("scx_sched_ref_test_{}", std::process::id()));
1478        let scheds = base.join("scheds/rust");
1479        let target = scheds.join("scx_target");
1480        let other = scheds.join("scx_other");
1481        std::fs::create_dir_all(target.join("src/bpf")).unwrap();
1482        std::fs::create_dir_all(other.join("src/bpf")).unwrap();
1483        std::fs::write(target.join("src/bpf/main.bpf.c"), "target_policy();\n").unwrap();
1484        std::fs::write(
1485            other.join("src/bpf/main.bpf.c"),
1486            "void pick_remote(void) {}\n",
1487        )
1488        .unwrap();
1489
1490        let listed = execute_tool(&target, Some(&scheds), "list_schedulers", "{}", false).unwrap();
1491        assert!(listed.contains("scx_other"), "got: {listed}");
1492        assert!(listed.contains("scx_target"), "got: {listed}");
1493
1494        let grep_args = json!({
1495            "pattern": "pick_remote",
1496            "scheduler": "scx_other",
1497            "glob": "main.bpf.c",
1498        });
1499        let matches = execute_tool(
1500            &target,
1501            Some(&scheds),
1502            "grep_schedulers",
1503            &grep_args.to_string(),
1504            false,
1505        )
1506        .unwrap();
1507        assert!(
1508            matches.contains("scx_other/src/bpf/main.bpf.c:1:void pick_remote"),
1509            "got: {matches}"
1510        );
1511
1512        let read_args = json!({
1513            "scheduler": "scx_other",
1514            "path": "src/bpf/main.bpf.c",
1515        });
1516        let read = execute_tool(
1517            &target,
1518            Some(&scheds),
1519            "read_scheduler_file",
1520            &read_args.to_string(),
1521            false,
1522        )
1523        .unwrap();
1524        assert!(read.contains("scheduler: scx_other"), "got: {read}");
1525        assert!(read.contains("file: src/bpf/main.bpf.c"), "got: {read}");
1526        assert!(read.contains("pick_remote"), "got: {read}");
1527
1528        let read_past_eof_args = json!({
1529            "scheduler": "scx_other",
1530            "path": "src/bpf/main.bpf.c",
1531            "start_line": 10,
1532        });
1533        let read_past_eof = execute_tool(
1534            &target,
1535            Some(&scheds),
1536            "read_scheduler_file",
1537            &read_past_eof_args.to_string(),
1538            false,
1539        )
1540        .unwrap();
1541        assert!(
1542            read_past_eof.contains("scheduler: scx_other"),
1543            "got: {read_past_eof}"
1544        );
1545        assert!(
1546            read_past_eof.contains("requested start_line 10 is past EOF"),
1547            "got: {read_past_eof}"
1548        );
1549
1550        let simple_args = json!({
1551            "scheduler": "scx_simple",
1552            "path": "src/bpf/main.bpf.c",
1553        });
1554        let err = execute_tool(
1555            &target,
1556            Some(&scheds),
1557            "read_scheduler_file",
1558            &simple_args.to_string(),
1559            false,
1560        )
1561        .unwrap_err()
1562        .to_string();
1563        assert!(err.contains("scx_simple is not available"), "got: {err}");
1564        assert!(err.contains("call list_schedulers"), "got: {err}");
1565
1566        let edit_args = json!({
1567            "path": "../scx_other/src/bpf/main.bpf.c",
1568            "old_string": "pick_remote",
1569            "new_string": "stolen_policy",
1570        });
1571        let err = execute_tool(
1572            &target,
1573            Some(&scheds),
1574            "edit_file",
1575            &edit_args.to_string(),
1576            true,
1577        )
1578        .unwrap_err()
1579        .to_string();
1580        assert!(err.contains("path must be crate-relative"), "got: {err}");
1581
1582        let _ = std::fs::remove_dir_all(&base);
1583    }
1584
1585    #[tokio::test]
1586    async fn async_dispatch_runs_local_tool() {
1587        let base = std::env::temp_dir().join(format!("scx_tools_async_{}", std::process::id()));
1588        std::fs::create_dir_all(&base).unwrap();
1589        std::fs::write(base.join("README.md"), "hello\n").unwrap();
1590
1591        let out = execute_tool_async(&base, None, "read_file", r#"{"path":"README.md"}"#, false)
1592            .await
1593            .unwrap();
1594
1595        assert!(out.contains("hello"));
1596        let _ = std::fs::remove_dir_all(&base);
1597    }
1598
1599    #[test]
1600    fn web_fetch_is_exposed_and_rejects_private_targets() {
1601        let tools = openai_tools_json(false, true);
1602        let names: Vec<String> = tools
1603            .as_array()
1604            .unwrap()
1605            .iter()
1606            .filter_map(|tool| {
1607                tool.get("function")
1608                    .and_then(|f| f.get("name"))
1609                    .and_then(|name| name.as_str())
1610                    .map(str::to_string)
1611            })
1612            .collect();
1613
1614        assert!(names.iter().any(|name| name == "web_fetch"));
1615        assert!(is_disallowed_host("localhost"));
1616        assert!(is_disallowed_host("docs.local"));
1617        assert!(is_disallowed_ip("127.0.0.1".parse().unwrap()));
1618        assert!(is_disallowed_ip("10.1.2.3".parse().unwrap()));
1619        assert!(is_disallowed_ip("::1".parse().unwrap()));
1620        assert!(!is_disallowed_ip("93.184.216.34".parse().unwrap()));
1621    }
1622
1623    #[test]
1624    fn web_fetch_extracts_basic_html_text() {
1625        let html = r#"
1626            <html><head><style>.x{}</style><script>bad()</script></head>
1627            <body><h1>EEVDF scheduling</h1><p>Virtual deadline &amp; lag.</p></body></html>
1628        "#;
1629        let text = html_to_text(html);
1630
1631        assert!(text.contains("EEVDF scheduling"));
1632        assert!(text.contains("Virtual deadline & lag."));
1633        assert!(!text.contains("bad()"));
1634    }
1635
1636    #[test]
1637    fn large_scheduler_reference_file_requires_bounds() {
1638        let base =
1639            std::env::temp_dir().join(format!("scx_sched_ref_large_test_{}", std::process::id()));
1640        let scheds = base.join("scheds/rust");
1641        let target = scheds.join("scx_target");
1642        let other = scheds.join("scx_other");
1643        std::fs::create_dir_all(&target).unwrap();
1644        std::fs::create_dir_all(other.join("src/bpf")).unwrap();
1645        let large = (0..3000)
1646            .map(|i| format!("void helper_{i}(void) {{ pick_remote(); }}\n"))
1647            .collect::<String>();
1648        std::fs::write(other.join("src/bpf/main.bpf.c"), large).unwrap();
1649
1650        let unbounded_args = json!({
1651            "scheduler": "scx_other",
1652            "path": "src/bpf/main.bpf.c",
1653        });
1654        let unbounded = execute_tool(
1655            &target,
1656            Some(&scheds),
1657            "read_scheduler_file",
1658            &unbounded_args.to_string(),
1659            false,
1660        )
1661        .unwrap();
1662        assert!(
1663            unbounded.contains("full unbounded read suppressed"),
1664            "got: {unbounded}"
1665        );
1666        assert!(unbounded.contains("grep_schedulers"), "got: {unbounded}");
1667
1668        let bounded_args = json!({
1669            "scheduler": "scx_other",
1670            "path": "src/bpf/main.bpf.c",
1671            "start_line": 10,
1672            "end_line": 12,
1673        });
1674        let bounded = execute_tool(
1675            &target,
1676            Some(&scheds),
1677            "read_scheduler_file",
1678            &bounded_args.to_string(),
1679            false,
1680        )
1681        .unwrap();
1682        assert!(bounded.contains("helper_9"), "got: {bounded}");
1683        assert!(bounded.contains("helper_11"), "got: {bounded}");
1684
1685        let _ = std::fs::remove_dir_all(&base);
1686    }
1687}