1pub mod approval;
2pub mod bounded;
3pub mod sandbox;
4
5#[cfg(feature = "local-tools")]
6pub mod local;
7
8#[cfg(feature = "e2b")]
9pub mod e2b;
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex};
12
13use async_trait::async_trait;
14use serde_json::{json, Value};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ToolSpec {
22 pub name: String,
23 pub description: String,
24 pub input_schema: Value,
26}
27
28#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29pub struct ToolInvocation {
30 pub id: String,
31 pub name: String,
32 pub input: Value,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub struct ToolOutcome {
37 pub output: Result<Value, ToolFailure>,
38 pub attachments: Vec<crate::model::UserAttachment>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ToolFailureKind {
50 InvalidInput,
51 NotFound,
52 NonZeroExit,
53 Timeout,
54 Runtime,
55 Denied,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ToolFailure {
62 pub kind: ToolFailureKind,
63 pub message: String,
64}
65
66impl ToolFailure {
67 pub fn new(kind: ToolFailureKind, message: impl Into<String>) -> Self {
68 Self {
69 kind,
70 message: message.into(),
71 }
72 }
73}
74
75impl std::fmt::Display for ToolFailure {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 write!(f, "{:?}: {}", self.kind, self.message)
78 }
79}
80
81pub fn invalid_input_failure(
82 tool: &str,
83 message: impl AsRef<str>,
84 input: &Value,
85 schema: Option<&Value>,
86) -> ToolFailure {
87 ToolFailure::new(
88 ToolFailureKind::InvalidInput,
89 format_invalid_input_message(tool, message.as_ref(), input, schema),
90 )
91}
92
93pub fn format_invalid_input_message(
94 tool: &str,
95 detail: &str,
96 input: &Value,
97 schema: Option<&Value>,
98) -> String {
99 let received = received_fields(input);
100 let summaries = summarize_input_fields(input);
101 let mut message = format!(
102 "The {tool} tool was called with invalid arguments: {detail}. \
103Please rewrite the input so it satisfies the expected schema."
104 );
105 if !received.is_empty() {
106 message.push_str(&format!(" Received fields: {}.", received.join(", ")));
107 }
108 if !summaries.is_empty() {
109 message.push_str(&format!(" Field summary: {}.", summaries.join("; ")));
110 }
111 if let Some(schema) = schema {
114 let example = crate::tool_repair::example_for_schema(schema);
115 if example.as_object().is_some_and(|o| !o.is_empty()) {
116 message.push_str(&format!(" Expected shape: {example}."));
117 }
118 }
119 message
120}
121
122fn received_fields(input: &Value) -> Vec<String> {
123 let Some(obj) = input.as_object() else {
124 return vec![json_type(input).to_string()];
125 };
126 let mut keys: Vec<String> = obj.keys().cloned().collect();
127 keys.sort();
128 keys
129}
130
131fn summarize_input_fields(input: &Value) -> Vec<String> {
132 let Some(obj) = input.as_object() else {
133 return vec![format!("input: {}", summarize_value(input))];
134 };
135 let mut entries: Vec<_> = obj.iter().collect();
136 entries.sort_by(|a, b| a.0.cmp(b.0));
137 entries
138 .into_iter()
139 .take(12)
140 .map(|(key, value)| format!("{key}: {}", summarize_value(value)))
141 .collect()
142}
143
144fn summarize_value(value: &Value) -> String {
145 match value {
146 Value::String(s) => {
147 let preview: String = s.chars().take(80).collect();
148 let suffix = if s.chars().count() > 80 { "..." } else { "" };
149 format!("string({} chars, preview={:?}{suffix})", s.chars().count(), preview)
150 }
151 Value::Array(a) => format!("array({} items)", a.len()),
152 Value::Object(o) => format!("object({} keys)", o.len()),
153 Value::Bool(_) => "boolean".into(),
154 Value::Number(_) => "number".into(),
155 Value::Null => "null".into(),
156 }
157}
158
159fn json_type(value: &Value) -> &'static str {
160 match value {
161 Value::Null => "null",
162 Value::Bool(_) => "boolean",
163 Value::Number(_) => "number",
164 Value::String(_) => "string",
165 Value::Array(_) => "array",
166 Value::Object(_) => "object",
167 }
168}
169
170#[derive(Debug, thiserror::Error)]
171pub enum ToolRuntimeError {
172 #[error("unknown tool {0}")]
173 UnknownTool(String),
174
175 #[error("invalid input for {tool}: {message}")]
176 InvalidInput { tool: String, message: String },
177
178 #[error("tool timed out: {0}")]
179 Timeout(String),
180
181 #[error("tool runtime failed: {0}")]
182 Runtime(String),
183}
184
185#[async_trait]
186pub trait ToolRuntime: Send + Sync {
187 fn specs(&self) -> Vec<ToolSpec>;
188
189 fn repair_invocation(
198 &self,
199 _invocation: &mut ToolInvocation,
200 ) -> Option<Vec<crate::tool_repair::ToolInputRepair>> {
201 None
202 }
203
204 async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError>;
205
206 async fn invoke_cancellable(
219 &self,
220 invocation: ToolInvocation,
221 _cancel: Option<&tokio_util::sync::CancellationToken>,
222 ) -> Result<ToolOutcome, ToolRuntimeError> {
223 self.invoke(invocation).await
224 }
225}
226
227#[derive(Debug, Default, Clone)]
228pub struct MockToolRuntime {
229 files: Arc<Mutex<HashMap<String, String>>>,
230}
231
232impl MockToolRuntime {
233 pub fn new() -> Self {
234 Self::default()
235 }
236
237 pub fn with_file(self, path: impl Into<String>, content: impl Into<String>) -> Self {
238 self.files
239 .lock()
240 .unwrap()
241 .insert(path.into(), content.into());
242 self
243 }
244}
245
246#[async_trait]
247impl ToolRuntime for MockToolRuntime {
248 fn specs(&self) -> Vec<ToolSpec> {
249 builtin_tool_specs()
250 }
251
252 async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
253 match invocation.name.as_str() {
254 "bash" => {
255 let command = required_str(&invocation, "command")?;
256 Ok(ToolOutcome {
257 output: Ok(json!({
258 "command": command,
259 "stdout": format!("mock executed: {command}\n"),
260 "stderr": "",
261 "exit_code": 0,
262 })),
263 attachments: vec![],
264 })
265 }
266 "read" => {
267 let path = required_str(&invocation, "path")?;
268 let files = self.files.lock().unwrap();
269 match files.get(path) {
270 Some(content) => Ok(ToolOutcome {
271 output: Ok(json!({"path": path, "content": content})),
272 attachments: vec![],
273 }),
274 None => Ok(ToolOutcome {
275 output: Err(ToolFailure::new(
276 ToolFailureKind::NotFound,
277 format!("file not found: {path}"),
278 )),
279 attachments: vec![],
280 }),
281 }
282 }
283 "write" => {
284 let path = required_str(&invocation, "path")?.to_string();
285 let content = required_str(&invocation, "content")?.to_string();
286 self.files.lock().unwrap().insert(path.clone(), content);
287 Ok(ToolOutcome {
288 output: Ok(json!({"path": path, "written": true})),
289 attachments: vec![],
290 })
291 }
292 "edit" => {
293 let path = required_str(&invocation, "path")?.to_string();
294 let old_string = required_str(&invocation, "old_string")?.to_string();
295 let new_string = invocation
296 .input
297 .get("new_string")
298 .and_then(|v| v.as_str())
299 .unwrap_or("")
300 .to_string();
301 let replace_all = invocation
302 .input
303 .get("replace_all")
304 .and_then(|v| v.as_bool())
305 .unwrap_or(false);
306 let mut files = self.files.lock().unwrap();
307 let Some(content) = files.get(&path).cloned() else {
308 return Ok(ToolOutcome {
309 output: Err(ToolFailure::new(
310 ToolFailureKind::NotFound,
311 format!("file not found: {path}"),
312 )),
313 attachments: vec![],
314 });
315 };
316 let resolved = match resolve_edit_search(
317 &content,
318 &old_string,
319 &new_string,
320 replace_all,
321 ) {
322 Ok(r) => r,
323 Err(e) => {
324 let message = match e {
325 EditSearchError::NotFound => {
326 "Could not find old_string in the file. It must match exactly, including whitespace and indentation. Read the file again before retrying.".to_string()
327 }
328 EditSearchError::EscapedNotFound =>
329 "Could not find old_string in the file, even after checking for JSON-escaped text. It must match exactly, including whitespace and indentation. Read the file again before retrying.".to_string(),
330 EditSearchError::Ambiguous { occurrences } => format!(
331 "Found {occurrences} exact matches for old_string. Provide more surrounding context or set replace_all=true."
332 ),
333 EditSearchError::EscapedAmbiguous { occurrences } => format!(
334 "old_string appears JSON-escaped and matches {occurrences} occurrences after unescaping. Provide more surrounding context or set replace_all=true."
335 ),
336 };
337 return Ok(ToolOutcome {
338 output: Err(ToolFailure::new(ToolFailureKind::InvalidInput, message)),
339 attachments: vec![],
340 });
341 }
342 };
343 let next = if replace_all {
344 content.replace(&resolved.old_string, &resolved.new_string)
345 } else {
346 content.replacen(&resolved.old_string, &resolved.new_string, 1)
347 };
348 let replaced = if replace_all { resolved.occurrences } else { 1 };
349 files.insert(path.clone(), next);
350 if let Some(repair) = resolved.repair {
354 tracing::debug!(
355 target: "harness::tool_repair",
356 tool = "edit",
357 repair,
358 "edit applied after silent json-escape repair"
359 );
360 }
361 Ok(ToolOutcome {
362 output: Ok(json!({"path": path, "replaced": replaced})),
363 attachments: vec![],
364 })
365 }
366 "grep" => {
367 let pattern = required_str(&invocation, "pattern")?.to_string();
368 let case_insensitive = invocation
369 .input
370 .get("case_insensitive")
371 .and_then(|v| v.as_bool())
372 .unwrap_or(false);
373 let needle = if case_insensitive {
374 pattern.to_lowercase()
375 } else {
376 pattern.clone()
377 };
378 let files = self.files.lock().unwrap();
379 let mut matches = Vec::new();
380 for (path, content) in files.iter() {
381 for (idx, line) in content.lines().enumerate() {
382 let hay = if case_insensitive {
383 line.to_lowercase()
384 } else {
385 line.to_string()
386 };
387 if hay.contains(&needle) {
388 matches.push(json!({
389 "path": path,
390 "line": idx + 1,
391 "text": line,
392 }));
393 }
394 }
395 }
396 Ok(ToolOutcome {
397 output: Ok(json!({"pattern": pattern, "matches": matches})),
398 attachments: vec![],
399 })
400 }
401 "glob" => {
402 let pattern = required_str(&invocation, "pattern")?.to_string();
403 let files = self.files.lock().unwrap();
404 let matches: Vec<&str> = files
405 .keys()
406 .filter(|k| simple_glob_match(&pattern, k))
407 .map(|k| k.as_str())
408 .collect();
409 Ok(ToolOutcome {
410 output: Ok(json!({"pattern": pattern, "matches": matches})),
411 attachments: vec![],
412 })
413 }
414 other => Err(ToolRuntimeError::UnknownTool(other.into())),
415 }
416 }
417}
418
419#[derive(Debug)]
425pub struct ResolvedEditSearch {
426 pub old_string: String,
427 pub new_string: String,
428 pub occurrences: usize,
429 pub repair: Option<&'static str>,
430}
431
432#[derive(Debug, PartialEq)]
435pub enum EditSearchError {
436 NotFound,
438 EscapedNotFound,
441 Ambiguous { occurrences: usize },
443 EscapedAmbiguous { occurrences: usize },
445}
446
447pub fn resolve_edit_search(
453 content: &str,
454 old_string: &str,
455 new_string: &str,
456 replace_all: bool,
457) -> Result<ResolvedEditSearch, EditSearchError> {
458 let direct = content.matches(old_string).count();
459 if direct > 0 {
460 if !replace_all && direct > 1 {
461 return Err(EditSearchError::Ambiguous {
462 occurrences: direct,
463 });
464 }
465 return Ok(ResolvedEditSearch {
466 old_string: old_string.to_string(),
467 new_string: new_string.to_string(),
468 occurrences: direct,
469 repair: None,
470 });
471 }
472 if !has_literal_escaped_controls(old_string) {
473 return Err(EditSearchError::NotFound);
474 }
475 let unescaped_old = unescape_literal_controls(old_string);
476 if unescaped_old == old_string {
477 return Err(EditSearchError::NotFound);
478 }
479 let count = content.matches(&unescaped_old).count();
480 if count == 0 {
481 return Err(EditSearchError::EscapedNotFound);
482 }
483 if !replace_all && count > 1 {
484 return Err(EditSearchError::EscapedAmbiguous { occurrences: count });
485 }
486 Ok(ResolvedEditSearch {
487 old_string: unescaped_old,
488 new_string: unescape_literal_controls(new_string),
489 occurrences: count,
490 repair: Some("json_escape_unwrapped"),
491 })
492}
493
494fn has_literal_escaped_controls(s: &str) -> bool {
497 s.contains("\\n") || s.contains("\\t") || s.contains("\\r")
498}
499
500fn unescape_literal_controls(s: &str) -> String {
505 let bytes = s.as_bytes();
506 let mut out = Vec::with_capacity(bytes.len());
507 let mut i = 0;
508 while i < bytes.len() {
509 if bytes[i] == b'\\' && i + 1 < bytes.len() {
510 if bytes[i + 1] == b'r'
511 && i + 3 < bytes.len()
512 && bytes[i + 2] == b'\\'
513 && bytes[i + 3] == b'n'
514 {
515 out.push(b'\n');
516 i += 4;
517 continue;
518 }
519 let replacement = match bytes[i + 1] {
520 b'n' => Some(b'\n'),
521 b'r' => Some(b'\r'),
522 b't' => Some(b'\t'),
523 _ => None,
524 };
525 if let Some(ch) = replacement {
526 out.push(ch);
527 i += 2;
528 continue;
529 }
530 }
531 out.push(bytes[i]);
532 i += 1;
533 }
534 String::from_utf8(out).unwrap_or_else(|_| s.to_string())
537}
538
539pub fn simple_glob_match(pattern: &str, candidate: &str) -> bool {
545 if pattern.contains('{') {
546 return expand_braces(pattern)
549 .iter()
550 .any(|p| simple_glob_match_single(p, candidate));
551 }
552 simple_glob_match_single(pattern, candidate)
553}
554
555const MAX_BRACE_EXPANSIONS: usize = 128;
558
559fn expand_braces(pattern: &str) -> Vec<String> {
564 let chars: Vec<char> = pattern.chars().collect();
565 let Some(open) = chars.iter().position(|&c| c == '{') else {
566 return vec![pattern.to_string()];
567 };
568 let mut depth = 0usize;
570 let mut close = None;
571 for (i, &c) in chars.iter().enumerate().skip(open) {
572 match c {
573 '{' => depth += 1,
574 '}' => {
575 depth -= 1;
576 if depth == 0 {
577 close = Some(i);
578 break;
579 }
580 }
581 _ => {}
582 }
583 }
584 let Some(close) = close else {
585 return vec![pattern.to_string()]; };
587 let prefix: String = chars[..open].iter().collect();
588 let suffix: String = chars[close + 1..].iter().collect();
589 let mut alts: Vec<String> = Vec::new();
592 let mut cur = String::new();
593 let mut d = 0usize;
594 for &c in &chars[open + 1..close] {
595 match c {
596 '{' => {
597 d += 1;
598 cur.push(c);
599 }
600 '}' => {
601 d -= 1;
602 cur.push(c);
603 }
604 ',' if d == 0 => alts.push(std::mem::take(&mut cur)),
605 _ => cur.push(c),
606 }
607 }
608 alts.push(cur);
609 let mut out = Vec::new();
610 for alt in alts {
611 for expanded in expand_braces(&format!("{prefix}{alt}{suffix}")) {
612 out.push(expanded);
613 if out.len() >= MAX_BRACE_EXPANSIONS {
614 return out;
615 }
616 }
617 }
618 out
619}
620
621fn simple_glob_match_single(pattern: &str, candidate: &str) -> bool {
622 let pat: Vec<char> = pattern.chars().collect();
627 let cand: Vec<char> = candidate.chars().collect();
628 fn walk(pat: &[char], cand: &[char]) -> bool {
629 let mut p = 0usize;
630 let mut c = 0usize;
631 while p < pat.len() {
632 match pat[p] {
633 '*' if pat.get(p + 1) == Some(&'*') => {
634 let rest = &pat[p + 2..];
635 for end in c..=cand.len() {
636 if walk(rest, &cand[end..]) {
637 return true;
638 }
639 }
640 return false;
641 }
642 '*' => {
643 let rest = &pat[p + 1..];
644 while c <= cand.len() {
645 if walk(rest, &cand[c..]) {
646 return true;
647 }
648 if c == cand.len() || cand[c] == '/' {
649 return false;
650 }
651 c += 1;
652 }
653 return false;
654 }
655 '?' => {
656 if c >= cand.len() || cand[c] == '/' {
657 return false;
658 }
659 c += 1;
660 p += 1;
661 }
662 ch => {
663 if c >= cand.len() || cand[c] != ch {
664 return false;
665 }
666 c += 1;
667 p += 1;
668 }
669 }
670 }
671 c == cand.len()
672 }
673 walk(&pat, &cand)
674}
675
676pub const FS_GLOB_IGNORED_DIRS: &[&str] = &[
683 "node_modules",
684 "target",
685 ".git",
686 "dist",
687 "build",
688 "vendor",
689 ".next",
690 "__pycache__",
691 ".venv",
692];
693
694pub const MAX_FS_GLOB_RESULTS: usize = 2000;
699
700pub const MAX_OUTPUT_BYTES: usize = 50_000;
705
706const TAIL_SCAN_BYTES: usize = 2048;
710
711const ERROR_MARKERS: &[&str] = &[
714 "error",
715 "exception",
716 "failed",
717 "fatal",
718 "panic",
719 "traceback",
720 "exit code",
721];
722
723pub fn bounded_preview(full: &str, spill_path: &str) -> Option<String> {
733 if full.len() <= MAX_OUTPUT_BYTES {
734 return None;
735 }
736 Some(format!(
737 "{}\n\n[{} bytes total, truncated. Full output saved to {spill_path} — \
738use the read tool with offset/limit to fetch more.]",
739 head_tail_body(full),
740 full.len()
741 ))
742}
743
744pub fn clip_overflow(full: &str) -> String {
749 format!(
750 "{}\n\n[output clipped: {} bytes total exceeded the tool-output ceiling]",
751 head_tail_body(full),
752 full.len()
753 )
754}
755
756fn head_tail_body(full: &str) -> String {
759 let lines: Vec<&str> = full.split('\n').collect();
760 if tail_has_error(full) {
761 let head_budget = MAX_OUTPUT_BYTES * 7 / 10;
762 let head = take_lines_head(&lines, head_budget);
763 let tail = take_lines_tail(&lines, MAX_OUTPUT_BYTES - head_budget);
764 let omitted = lines
765 .len()
766 .saturating_sub(head.len())
767 .saturating_sub(tail.len());
768 format!(
769 "{}\n\n... {omitted} lines omitted — showing head and tail ...\n\n{}",
770 head.join("\n"),
771 tail.join("\n"),
772 )
773 } else {
774 take_lines_head(&lines, MAX_OUTPUT_BYTES).join("\n")
775 }
776}
777
778pub fn clip_head(s: String) -> String {
782 if s.len() <= MAX_OUTPUT_BYTES {
783 return s;
784 }
785 let mut end = MAX_OUTPUT_BYTES;
786 while end > 0 && !s.is_char_boundary(end) {
787 end -= 1;
788 }
789 format!(
790 "{}\n\n[content truncated: use offset/limit to read more]",
791 &s[..end]
792 )
793}
794
795fn tail_has_error(s: &str) -> bool {
797 let mut start = s.len().saturating_sub(TAIL_SCAN_BYTES);
798 while start > 0 && !s.is_char_boundary(start) {
799 start -= 1;
800 }
801 let scan = s[start..].to_ascii_lowercase();
802 ERROR_MARKERS.iter().any(|m| scan.contains(m))
803}
804
805fn take_lines_head<'a>(lines: &[&'a str], budget: usize) -> Vec<&'a str> {
808 let mut out = Vec::new();
809 let mut used = 0usize;
810 for (i, line) in lines.iter().enumerate() {
811 let cost = line.len() + usize::from(i > 0);
812 if used + cost > budget {
813 break;
814 }
815 out.push(*line);
816 used += cost;
817 }
818 out
819}
820
821fn take_lines_tail<'a>(lines: &[&'a str], budget: usize) -> Vec<&'a str> {
824 let mut out = Vec::new();
825 let mut used = 0usize;
826 for line in lines.iter().rev() {
827 let cost = line.len() + usize::from(!out.is_empty());
828 if used + cost > budget {
829 break;
830 }
831 out.push(*line);
832 used += cost;
833 }
834 out.reverse();
835 out
836}
837
838pub fn fs_glob(pattern: &str, base_dir: &std::path::Path) -> Vec<String> {
849 fs_glob_bounded(pattern, base_dir).0
850}
851
852pub fn fs_glob_bounded(pattern: &str, base_dir: &std::path::Path) -> (Vec<String>, bool) {
856 let mut matches = Vec::new();
857 let mut truncated = false;
858 let mut stack = vec![base_dir.to_path_buf()];
859 while let Some(dir) = stack.pop() {
860 let rd = match std::fs::read_dir(&dir) {
861 Ok(r) => r,
862 Err(_) => continue,
863 };
864 for entry in rd.flatten() {
865 let path = entry.path();
866 let rel = match path.strip_prefix(base_dir) {
867 Ok(r) => r.to_string_lossy().replace('\\', "/"),
868 Err(_) => continue,
869 };
870 let first = rel.split('/').next().unwrap_or("");
871 if first.starts_with('.') && !pattern.starts_with('.') {
872 continue;
873 }
874 if !path.is_symlink() && path.is_dir() {
875 let name = entry.file_name();
878 if FS_GLOB_IGNORED_DIRS.iter().any(|d| name.as_os_str() == *d) {
879 continue;
880 }
881 stack.push(path);
882 } else if !path.is_dir() && simple_glob_match(pattern, &rel) {
883 if matches.len() >= MAX_FS_GLOB_RESULTS {
884 truncated = true;
885 break;
886 }
887 matches.push(rel);
888 }
889 }
890 if truncated {
891 break;
892 }
893 }
894 matches.sort();
895 (matches, truncated)
896}
897
898pub fn builtin_tool_specs() -> Vec<ToolSpec> {
906 vec![
907 ToolSpec {
908 name: "bash".into(),
909 description: "Run a shell command inside the sandbox working directory. \
910 Returns structured command status + stdout/stderr, including non-zero \
911 exits and timeouts. Bounded by `timeout_ms` \
912 (default 120 000 ms, max 600 000 ms) — on timeout the process \
913 is terminated and any captured output is returned. For commands \
914 that may run longer than 10 min, use `nohup … &` writing to a \
915 file, then poll the file with the read tool across turns."
916 .into(),
917 input_schema: json!({
918 "type": "object",
919 "properties": {
920 "command": {
921 "type": "string",
922 "description": "Shell command to execute. Local runtimes prefer /bin/bash -lc when available and fall back to /bin/sh -lc."
923 },
924 "timeout_ms": {
925 "type": "integer",
926 "description": "Optional timeout in milliseconds (default 120000, max 600000).",
927 "minimum": 1000,
928 "maximum": 600000
929 },
930 "soft_timeout_ms": {
931 "type": "integer",
932 "description": "Optional no-output timeout in milliseconds (default 10000). Streaming output resets this timer.",
933 "minimum": 1000,
934 "maximum": 600000
935 }
936 },
937 "required": ["command"],
938 "additionalProperties": false
939 }),
940 },
941 ToolSpec {
942 name: "read".into(),
943 description:
944 "Read a UTF-8 file from the sandbox. Paginated by line: returns up to `limit` \
945 lines starting at `offset` (a 0-based line index). When the result is \
946 `truncated`, read the next page with the returned `next_offset`. Overlong \
947 lines are clipped."
948 .into(),
949 input_schema: json!({
950 "type": "object",
951 "properties": {
952 "path": {"type": "string"},
953 "offset": {
954 "type": "integer",
955 "description": "0-based line index to start from. Default 0.",
956 "minimum": 0
957 },
958 "limit": {
959 "type": "integer",
960 "description": "Max lines to return. Default 2000.",
961 "minimum": 1
962 }
963 },
964 "required": ["path"],
965 "additionalProperties": false
966 }),
967 },
968 ToolSpec {
969 name: "write".into(),
970 description: "Write UTF-8 content to a file in the sandbox.".into(),
971 input_schema: json!({
972 "type": "object",
973 "properties": {
974 "path": {"type": "string"},
975 "content": {"type": "string"}
976 },
977 "required": ["path", "content"],
978 "additionalProperties": false
979 }),
980 },
981 ToolSpec {
982 name: "edit".into(),
983 description:
984 "Edit a UTF-8 file by replacing an exact substring. By default `old_string` must \
985 appear exactly once; set `replace_all=true` to substitute every occurrence."
986 .into(),
987 input_schema: json!({
988 "type": "object",
989 "properties": {
990 "path": {"type": "string"},
991 "old_string": {
992 "type": "string",
993 "description": "Substring to replace; must match verbatim including whitespace."
994 },
995 "new_string": {
996 "type": "string",
997 "description": "Replacement text. Empty string deletes the match."
998 },
999 "replace_all": {
1000 "type": "boolean",
1001 "description": "When true, replace every occurrence. Default false (must be unique)."
1002 }
1003 },
1004 "required": ["path", "old_string", "new_string"],
1005 "additionalProperties": false
1006 }),
1007 },
1008 ToolSpec {
1009 name: "grep".into(),
1010 description:
1011 "Search file contents under a path using `grep -rnE` (extended regex). Returns \
1012 matching lines as `path:line:text`. Dependency and build directories \
1013 (node_modules, target, …) are skipped; the match count is capped and overlong \
1014 lines are clipped — a `truncated` flag signals when to narrow the pattern or path."
1015 .into(),
1016 input_schema: json!({
1017 "type": "object",
1018 "properties": {
1019 "pattern": {
1020 "type": "string",
1021 "description": "Regular expression to search for (passed to grep)."
1022 },
1023 "path": {
1024 "type": "string",
1025 "description": "Directory or file to search under. Default: current cwd."
1026 },
1027 "case_insensitive": {
1028 "type": "boolean",
1029 "description": "When true, pass -i to grep. Default false."
1030 }
1031 },
1032 "required": ["pattern"],
1033 "additionalProperties": false
1034 }),
1035 },
1036 ToolSpec {
1037 name: "glob".into(),
1038 description:
1039 "Find files matching a shell-style name pattern (e.g. `*.rs`). Returns relative \
1040 paths under the search root, one per line. Dependency and build directories \
1041 (node_modules, target, dist, …) are skipped, and the result count is capped — \
1042 a `truncated` flag signals when to narrow the pattern or search a subdirectory."
1043 .into(),
1044 input_schema: json!({
1045 "type": "object",
1046 "properties": {
1047 "pattern": {
1048 "type": "string",
1049 "description": "Shell glob like `*.rs` or `**/Cargo.toml`."
1050 },
1051 "path": {
1052 "type": "string",
1053 "description": "Directory to search under. Default: current cwd."
1054 }
1055 },
1056 "required": ["pattern"],
1057 "additionalProperties": false
1058 }),
1059 },
1060 ]
1061}
1062
1063fn required_str<'a>(
1064 invocation: &'a ToolInvocation,
1065 key: &str,
1066) -> Result<&'a str, ToolRuntimeError> {
1067 invocation
1068 .input
1069 .get(key)
1070 .and_then(|v| v.as_str())
1071 .filter(|s| !s.is_empty())
1072 .ok_or_else(|| ToolRuntimeError::InvalidInput {
1073 tool: invocation.name.clone(),
1074 message: format!("missing string field {key}"),
1075 })
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn bounded_preview_none_when_within_budget() {
1084 assert!(bounded_preview("short output", "/tmp/x.txt").is_none());
1085 }
1086
1087 #[test]
1088 fn bounded_preview_head_only_drops_tail_without_error() {
1089 let mut s = String::from("HEAD_MARKER\n");
1090 while s.len() < MAX_OUTPUT_BYTES + 10_000 {
1092 s.push_str("padding line of plain text\n");
1093 }
1094 s.push_str("LAST_LINE_NO_MARKER");
1095 let preview = bounded_preview(&s, "/tmp/out.txt").expect("over budget");
1096 assert!(preview.contains("HEAD_MARKER"));
1097 assert!(!preview.contains("LAST_LINE_NO_MARKER"), "tail leaked in head-only mode");
1098 assert!(preview.contains("/tmp/out.txt"));
1099 assert!(preview.contains("truncated"));
1100 }
1101
1102 #[test]
1103 fn bounded_preview_preserves_error_in_tail() {
1104 let mut s = String::from("HEAD_MARKER\n");
1105 while s.len() < MAX_OUTPUT_BYTES + 10_000 {
1106 s.push_str("padding line of plain text\n");
1107 }
1108 s.push_str("ERROR: the build failed at the end");
1109 let preview = bounded_preview(&s, "/tmp/out.txt").expect("over budget");
1110 assert!(preview.contains("HEAD_MARKER"));
1112 assert!(preview.contains("ERROR: the build failed at the end"));
1113 assert!(preview.contains("omitted"));
1114 }
1115
1116 #[test]
1117 fn clip_head_passes_short_strings_through() {
1118 assert_eq!(clip_head("hi".into()), "hi");
1119 }
1120
1121 #[test]
1122 fn simple_glob_matches_star_and_doublestar() {
1123 assert!(simple_glob_match("*.rs", "main.rs"));
1124 assert!(!simple_glob_match("*.rs", "main.rs.bak"));
1125 assert!(!simple_glob_match("*.rs", "src/main.rs"));
1126 assert!(simple_glob_match("**/*.rs", "src/main.rs"));
1127 assert!(simple_glob_match("**/*.rs", "a/b/c.rs"));
1128 assert!(simple_glob_match("Cargo.toml", "Cargo.toml"));
1129 assert!(!simple_glob_match("Cargo.toml", "Cargo.lock"));
1130 }
1131
1132 #[test]
1133 fn simple_glob_matches_brace_alternation() {
1134 assert!(simple_glob_match("**/*.{ts,tsx}", "src/main.ts"));
1136 assert!(simple_glob_match("**/*.{ts,tsx}", "src/components/App.tsx"));
1137 assert!(!simple_glob_match("**/*.{ts,tsx}", "src/main.rs"));
1138 assert!(simple_glob_match("{src,lib}/*.{ts,js}", "lib/util.js"));
1140 assert!(!simple_glob_match("{src,lib}/*.{ts,js}", "bin/util.js"));
1141 assert!(simple_glob_match("*.{t{s,sx}}", "x.tsx"));
1143 assert!(simple_glob_match("*.{t{s,sx}}", "x.ts"));
1144 assert!(simple_glob_match("*.{rs}", "main.rs"));
1146 assert!(simple_glob_match("a{,b}c", "ac"));
1147 assert!(simple_glob_match("a{,b}c", "abc"));
1148 assert!(simple_glob_match("a{b", "a{b"));
1150 assert!(!simple_glob_match("a{b", "ab"));
1151 }
1152
1153 #[test]
1154 fn expand_braces_caps_pathological_patterns() {
1155 let pat = "{a,b,c,d}{a,b,c,d}{a,b,c,d}{a,b,c,d}";
1157 assert_eq!(expand_braces(pat).len(), MAX_BRACE_EXPANSIONS);
1158 }
1159
1160 #[tokio::test]
1161 async fn mock_runtime_edit_replaces_unique_substring() {
1162 let rt = MockToolRuntime::new().with_file("a.txt", "hello world");
1163 let out = rt
1164 .invoke(ToolInvocation {
1165 id: "tc_edit".into(),
1166 name: "edit".into(),
1167 input: json!({
1168 "path": "a.txt",
1169 "old_string": "world",
1170 "new_string": "rust",
1171 }),
1172 })
1173 .await
1174 .unwrap()
1175 .output
1176 .unwrap();
1177 assert_eq!(out["replaced"], 1);
1178 let after = rt
1180 .invoke(ToolInvocation {
1181 id: "tc_read".into(),
1182 name: "read".into(),
1183 input: json!({"path": "a.txt"}),
1184 })
1185 .await
1186 .unwrap()
1187 .output
1188 .unwrap();
1189 assert_eq!(after["content"], "hello rust");
1190 }
1191
1192 #[tokio::test]
1193 async fn mock_runtime_edit_rejects_ambiguous_match() {
1194 let rt = MockToolRuntime::new().with_file("a.txt", "foo foo");
1195 let failure = rt
1196 .invoke(ToolInvocation {
1197 id: "tc_edit".into(),
1198 name: "edit".into(),
1199 input: json!({"path": "a.txt", "old_string": "foo", "new_string": "bar"}),
1200 })
1201 .await
1202 .unwrap()
1203 .output
1204 .unwrap_err();
1205 assert_eq!(failure.kind, ToolFailureKind::InvalidInput);
1206 }
1207
1208 #[test]
1211 fn unescape_literal_controls_handles_sequences() {
1212 assert_eq!(unescape_literal_controls(r"a\nb"), "a\nb");
1213 assert_eq!(unescape_literal_controls(r"a\tb"), "a\tb");
1214 assert_eq!(unescape_literal_controls(r"a\rb"), "a\rb");
1215 assert_eq!(unescape_literal_controls(r"a\r\nb"), "a\nb");
1217 assert_eq!(unescape_literal_controls(r"a\\nb"), "a\\\nb");
1220 assert_eq!(unescape_literal_controls("plain"), "plain");
1222 }
1223
1224 #[test]
1225 fn resolve_edit_search_prefers_direct_match() {
1226 let r = resolve_edit_search("say \\n here", r"\n", "x", false).unwrap();
1229 assert!(r.repair.is_none());
1230 assert_eq!(r.old_string, r"\n");
1231 }
1232
1233 #[test]
1234 fn resolve_edit_search_unescapes_literal_controls() {
1235 let r = resolve_edit_search("line1\nline2", r"line1\nline2", r"a\tb", false).unwrap();
1236 assert_eq!(r.repair, Some("json_escape_unwrapped"));
1237 assert_eq!(r.old_string, "line1\nline2");
1238 assert_eq!(r.new_string, "a\tb"); assert_eq!(r.occurrences, 1);
1240 }
1241
1242 #[test]
1243 fn resolve_edit_search_escaped_not_found() {
1244 assert_eq!(
1245 resolve_edit_search("other", r"line1\nline2", "x", false).unwrap_err(),
1246 EditSearchError::EscapedNotFound
1247 );
1248 }
1249
1250 #[test]
1251 fn resolve_edit_search_escaped_ambiguous_without_replace_all() {
1252 let content = "a\nb a\nb";
1253 assert_eq!(
1254 resolve_edit_search(content, r"a\nb", "x", false).unwrap_err(),
1255 EditSearchError::EscapedAmbiguous { occurrences: 2 }
1256 );
1257 let r = resolve_edit_search(content, r"a\nb", "x", true).unwrap();
1259 assert_eq!(r.occurrences, 2);
1260 assert_eq!(r.repair, Some("json_escape_unwrapped"));
1261 }
1262
1263 #[tokio::test]
1264 async fn mock_runtime_edit_repairs_json_escaped_old_string() {
1265 let rt = MockToolRuntime::new().with_file("a.txt", "line1\nline2\nline3");
1266 let out = rt
1267 .invoke(ToolInvocation {
1268 id: "tc_edit".into(),
1269 name: "edit".into(),
1270 input: json!({"path": "a.txt", "old_string": "line1\\nline2", "new_string": "merged"}),
1272 })
1273 .await
1274 .unwrap()
1275 .output
1276 .unwrap();
1277 assert_eq!(out["replaced"], 1);
1278 assert!(out.get("repair").is_none(), "repair leaked into output: {out}");
1281 let after = rt
1282 .invoke(ToolInvocation {
1283 id: "tc_read".into(),
1284 name: "read".into(),
1285 input: json!({"path": "a.txt"}),
1286 })
1287 .await
1288 .unwrap()
1289 .output
1290 .unwrap();
1291 assert_eq!(after["content"], "merged\nline3");
1292 }
1293
1294 #[tokio::test]
1295 async fn mock_runtime_grep_finds_matches() {
1296 let rt = MockToolRuntime::new()
1297 .with_file("a.txt", "alpha\nbeta\nALPHA")
1298 .with_file("b.txt", "gamma");
1299 let out = rt
1300 .invoke(ToolInvocation {
1301 id: "tc_grep".into(),
1302 name: "grep".into(),
1303 input: json!({"pattern": "alpha", "case_insensitive": true}),
1304 })
1305 .await
1306 .unwrap()
1307 .output
1308 .unwrap();
1309 let matches = out["matches"].as_array().unwrap();
1310 assert_eq!(matches.len(), 2);
1311 }
1312
1313 #[tokio::test]
1314 async fn mock_runtime_glob_matches_by_pattern() {
1315 let rt = MockToolRuntime::new()
1316 .with_file("src/main.rs", "")
1317 .with_file("src/lib.rs", "")
1318 .with_file("Cargo.toml", "");
1319 let out = rt
1320 .invoke(ToolInvocation {
1321 id: "tc_glob".into(),
1322 name: "glob".into(),
1323 input: json!({"pattern": "**/*.rs"}),
1324 })
1325 .await
1326 .unwrap()
1327 .output
1328 .unwrap();
1329 let matches = out["matches"].as_array().unwrap();
1330 assert_eq!(matches.len(), 2);
1331 }
1332
1333 #[tokio::test]
1334 async fn mock_runtime_supports_bash_read_write() {
1335 let rt = MockToolRuntime::new().with_file("README.md", "hello");
1336 let read = rt
1337 .invoke(ToolInvocation {
1338 id: "tc_read".into(),
1339 name: "read".into(),
1340 input: json!({"path":"README.md"}),
1341 })
1342 .await
1343 .unwrap();
1344 assert_eq!(read.output.unwrap()["content"], "hello");
1345
1346 let write = rt
1347 .invoke(ToolInvocation {
1348 id: "tc_write".into(),
1349 name: "write".into(),
1350 input: json!({"path":"out.txt", "content":"ok"}),
1351 })
1352 .await
1353 .unwrap();
1354 assert_eq!(write.output.unwrap()["written"], true);
1355
1356 let bash = rt
1357 .invoke(ToolInvocation {
1358 id: "tc_bash".into(),
1359 name: "bash".into(),
1360 input: json!({"command":"pwd"}),
1361 })
1362 .await
1363 .unwrap();
1364 assert_eq!(bash.output.unwrap()["exit_code"], 0);
1365 }
1366
1367 #[test]
1368 fn fs_glob_prunes_dependency_dirs() {
1369 use std::fs;
1373 let root = std::env::temp_dir().join(format!("harness_fsglob_{}", std::process::id()));
1374 let _ = fs::remove_dir_all(&root);
1375 for sub in ["src", "node_modules/dep", "target/debug"] {
1376 fs::create_dir_all(root.join(sub)).unwrap();
1377 }
1378 fs::write(root.join("keep.rs"), "").unwrap();
1379 fs::write(root.join("src/lib.rs"), "").unwrap();
1380 fs::write(root.join("node_modules/dep/skip.rs"), "").unwrap();
1381 fs::write(root.join("target/debug/skip.rs"), "").unwrap();
1382
1383 let (matches, truncated) = fs_glob_bounded("**.rs", &root);
1384 let _ = fs::remove_dir_all(&root);
1385
1386 assert!(!truncated);
1387 assert!(matches.iter().any(|m| m == "keep.rs"), "{matches:?}");
1388 assert!(matches.iter().any(|m| m == "src/lib.rs"), "{matches:?}");
1389 assert!(
1390 !matches
1391 .iter()
1392 .any(|m| m.contains("node_modules") || m.contains("target")),
1393 "pruned dirs leaked into results: {matches:?}"
1394 );
1395 }
1396}