1use 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
30pub 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
219fn 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 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: ®ex::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(" ", " ")
861 .replace("&", "&")
862 .replace("<", "<")
863 .replace(">", ">")
864 .replace(""", "\"")
865 .replace("'", "'")
866 .replace("'", "'")
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 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 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 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
1099fn 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; }
1129 found = Some(s);
1130 }
1131 }
1132 let s = found?;
1133 Some((offsets[s], offsets[s + w]))
1134}
1135
1136fn 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
1153fn 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 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
1181pub fn is_write_tool(name: &str) -> bool {
1183 name == "edit_file"
1184}
1185
1186pub 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
1225pub 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 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 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 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 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(); 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 & 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}