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