Skip to main content

harness/tools/
mod.rs

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/// LLM-facing description of one tool. `input_schema` is a JSON Schema
17/// object the model uses to generate well-formed `tool_call.arguments`.
18/// Shared between providers — OpenAI wraps it in `{type:"function", function:{...}}`,
19/// future Anthropic client will pass it as Messages API `input_schema` verbatim.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ToolSpec {
22    pub name: String,
23    pub description: String,
24    /// JSON Schema for `tool_call.arguments`. Must be an `object`-typed schema.
25    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    /// Structured non-text attachments returned by the tool — currently
39    /// just images returned by MCP servers (e.g. a screenshot tool).
40    /// Empty Vec for native tools (`SandboxToolRuntime`) — none of them
41    /// produce non-text output today. Reuses `UserAttachment` rather
42    /// than introducing a parallel `ToolAttachment` enum because both
43    /// shapes are identical (image source); rename to `MediaAttachment`
44    /// if a future tool variant differs.
45    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    /// Rejected by policy before dispatch (e.g. a shell command on the
56    /// hard-deny list). The message tells the model why so it can adjust.
57    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    // Teaching: show a minimal valid example synthesized from the schema so
112    // the model sees the exact shape it should have produced.
113    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    /// Apply schema-guided input repair in place, returning the repairs made
190    /// (or `None` when the input is already clean / no schema matches).
191    ///
192    /// The default is a no-op; [`bounded::BoundedToolRuntime`] overrides it as
193    /// the single source of truth for repair. `agent_loop` calls this BEFORE
194    /// recording the invocation in history / events so the recorded arguments
195    /// match what the runtime ultimately executes; the wrapper re-applies it
196    /// during dispatch (idempotent) to also cover bypass callers.
197    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    /// Cancellation-aware variant of `invoke`. When `cancel` is fired
207    /// the runtime SHOULD abort the in-flight tool (e.g. SIGTERM the
208    /// shell subprocess for `bash`) and return `ToolRuntimeError::
209    /// Runtime("cancelled")`.
210    ///
211    /// Default impl ignores the cancel handle and delegates to
212    /// `invoke` — agent_loop layers a `tokio::select!` on top so the
213    /// outer future is dropped on cancel even when the inner runtime
214    /// can't propagate. The cost is a leaked tool-side process until
215    /// it exits naturally; runtimes whose tools can take minutes
216    /// (shells, network tools) should override this to forward the
217    /// signal.
218    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                // Repair is silent: a successful edit reports only the result.
351                // The json-escape rescue is recorded for tracing, never
352                // surfaced to the model (MiMoCode "success silent" policy).
353                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/// Successful resolution of an edit's search/replace strings against file
420/// content. `repair` is `Some("json_escape_unwrapped")` when the match
421/// only succeeded after unescaping literal `\n` / `\t` / `\r` sequences —
422/// weak models frequently double-escape control characters when emitting
423/// `old_string` through JSON tool arguments.
424#[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/// Why an edit search failed to resolve. Callers build the user-facing
433/// message (each runtime embeds the path differently).
434#[derive(Debug, PartialEq)]
435pub enum EditSearchError {
436    /// `old_string` not in content, no escape rescue applicable.
437    NotFound,
438    /// `old_string` looked JSON-escaped, but the unescaped text is not in
439    /// the content either.
440    EscapedNotFound,
441    /// Direct match is ambiguous without `replace_all`.
442    Ambiguous { occurrences: usize },
443    /// Unescaped match is ambiguous without `replace_all`.
444    EscapedAmbiguous { occurrences: usize },
445}
446
447/// Resolve `old_string` against `content`, falling back to unescaping
448/// literal control sequences when the strict match fails. The direct
449/// match keeps our existing ambiguity guard; the escape fallback mirrors
450/// unescape both old and new strings, require a match, and reject
451/// multi-location matches without `replace_all`.
452pub 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
494/// Does the string contain literal (two-character) `\n` / `\t` / `\r`
495/// escape sequences?
496fn has_literal_escaped_controls(s: &str) -> bool {
497    s.contains("\\n") || s.contains("\\t") || s.contains("\\r")
498}
499
500/// Replace literal escape sequences with their control characters:
501/// `\r\n` → newline (checked first), then
502/// `\n` → newline, `\r` → CR, `\t` → tab. Anything else passes through —
503/// including `\\` — so `\\n` becomes `\` + newline, matching Go.
504fn 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    // Only ASCII subsequences were replaced; multi-byte UTF-8 passes
535    // through verbatim, so the result is valid UTF-8.
536    String::from_utf8(out).unwrap_or_else(|_| s.to_string())
537}
538
539/// Glob-match a pattern against a relative path string.
540/// Supports `*` (any chars except `/`), `**` (any chars including `/`), `?`,
541/// and `{a,b}` brace alternation (models emit `**/*.{ts,tsx}` reflexively —
542/// silently treating `{` as a literal made such patterns match nothing).
543/// Used by `MockToolRuntime` and by `fs_glob` for real-filesystem walks.
544pub fn simple_glob_match(pattern: &str, candidate: &str) -> bool {
545    if pattern.contains('{') {
546        // expand_braces returns fully-expanded patterns (no `{` groups left
547        // except literal unbalanced ones), so match each directly.
548        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
555/// Cap on patterns produced by brace expansion — a backstop against
556/// pathological nesting like `{a,b}{c,d}{e,f}…` multiplying without bound.
557const MAX_BRACE_EXPANSIONS: usize = 128;
558
559/// Expand the first balanced `{a,b,…}` group into one pattern per
560/// alternative, recursing so nested groups and multiple groups multiply
561/// out. A pattern without braces (or with an unbalanced `{`, kept as a
562/// literal) returns itself. Output is capped at [`MAX_BRACE_EXPANSIONS`].
563fn 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    // Find the matching close brace, tracking nesting depth.
569    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()]; // unbalanced → literal `{`
586    };
587    let prefix: String = chars[..open].iter().collect();
588    let suffix: String = chars[close + 1..].iter().collect();
589    // Split alternatives on top-level commas only (commas inside nested
590    // groups belong to the inner group).
591    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    // Translate the glob to a regex-lite pattern by walking byte-by-byte.
623    // `**` matches anything (including separators); `*` matches any chars
624    // except `/`; `?` matches a single non-`/` char; everything else is
625    // literal. Keep it simple: O(N*M) recursive descent.
626    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
676/// Directory names pruned before descent during a glob walk. These hold
677/// build artefacts / dependency trees that a name-pattern search almost
678/// never wants and that can balloon a result list into the megabytes —
679/// enough to blow past the model's context window when the list rides back
680/// into history. Pruned unconditionally (the walk never enters them), which
681/// also keeps the walk fast on large repos.
682pub 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
694/// Hard ceiling on paths returned by a single glob walk. Reaching it stops
695/// the walk early (rather than collecting the whole tree and trimming after)
696/// so a pathological pattern can't allocate an unbounded list before we cap
697/// it. Callers learn whether the cap was hit via [`fs_glob_bounded`].
698pub const MAX_FS_GLOB_RESULTS: usize = 2000;
699
700/// Output size cap before spilling to a temporary file. When a tool produces
701/// more bytes than this, runtimes write the full content to
702/// `/tmp/harness_out_<call_id>_<suffix>.txt` and return a preview with the
703/// path so the model can fetch the rest with the read tool if needed.
704pub const MAX_OUTPUT_BYTES: usize = 50_000;
705
706/// Tail bytes scanned for an error signature when bounding output. If the
707/// failure the model needs is in the last chunk, head-only truncation would
708/// drop it — so we detect it and keep a tail slice instead.
709const TAIL_SCAN_BYTES: usize = 2048;
710
711/// Case-insensitive substrings that mark a line worth preserving in the tail
712/// of a truncated output (mirrors MiMoCode's truncation heuristic).
713const ERROR_MARKERS: &[&str] = &[
714    "error",
715    "exception",
716    "failed",
717    "fatal",
718    "panic",
719    "traceback",
720    "exit code",
721];
722
723/// Compute the preview for an over-budget tool output. Returns `None` when
724/// `full` fits within [`MAX_OUTPUT_BYTES`] (caller uses it unchanged, no
725/// spill). Otherwise returns a preview the caller should surface AFTER
726/// writing `full` to `spill_path`:
727/// * if the tail carries an error signature, keep head (70% budget) AND tail
728///   (30%) so the failure survives the cut;
729/// * else keep head only.
730///
731/// The preview ends with a hint pointing at the spilled file.
732pub 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
744/// Error-aware head+tail clip for outputs that have NO spill file but blew
745/// past a hard ceiling — the catch-all in [`bounded::BoundedToolRuntime`] for
746/// tools (MCP, custom) that don't self-bound. Always returns a bounded
747/// string with a note (no file reference).
748pub 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
756/// Build the truncated body (no surrounding note): head+tail when the tail
757/// carries an error signature so the failure survives, else head only.
758fn 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
778/// Head-only clip with a note, for outputs that have NO spill file (read
779/// `content`, grep error text). Returns the string unchanged when within
780/// budget; otherwise clips at a char boundary near [`MAX_OUTPUT_BYTES`].
781pub 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
795/// Does the last [`TAIL_SCAN_BYTES`] of `s` contain an error marker?
796fn 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
805/// Collect whole lines from the front until adding the next would exceed
806/// `budget` bytes (counting the rejoining `\n`).
807fn 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
821/// Collect whole lines from the back until adding the next would exceed
822/// `budget` bytes; returned in original order.
823fn 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
838/// Walk `base_dir` recursively and return relative paths that match `pattern`.
839/// Skips hidden directories (`.git`, `.DS_Store`, etc.) unless the pattern
840/// explicitly starts with `.`, prunes dependency / build directories
841/// ([`FS_GLOB_IGNORED_DIRS`]), and caps the result count at
842/// [`MAX_FS_GLOB_RESULTS`]. Results are sorted lexicographically.
843/// Intended for use by production `ToolRuntime` implementations.
844///
845/// When the caller needs to know whether the cap was reached (e.g. to tell
846/// the model the list was truncated), use [`fs_glob_bounded`] — this wrapper
847/// discards that flag for the common case.
848pub fn fs_glob(pattern: &str, base_dir: &std::path::Path) -> Vec<String> {
849    fs_glob_bounded(pattern, base_dir).0
850}
851
852/// Like [`fs_glob`] but also reports whether the [`MAX_FS_GLOB_RESULTS`] cap
853/// was hit. A `true` second element means the returned list is a prefix of
854/// the full match set and the search should be narrowed.
855pub 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                // Prune dependency / build trees before descending so their
876                // (often enormous) contents are never read at all.
877                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
898/// Canonical specs for the built-in tool set (bash / read / write). Shared
899/// between `MockToolRuntime` (in-process tests) and the production
900/// `SandboxToolRuntime` (in `core` crate) so both report the same schema to
901/// the model. Adding a new built-in tool changes one place.
902///
903/// Note on `additionalProperties: false`: keeps the model from inventing
904/// fields. Required for some providers' strict tool-calling mode.
905pub 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        // ~60KB of innocuous lines, no error markers anywhere.
1091        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        // Head+tail mode: both the head AND the trailing error survive.
1111        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        // The exact shape models emit reflexively.
1135        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        // Multiple groups multiply out.
1139        assert!(simple_glob_match("{src,lib}/*.{ts,js}", "lib/util.js"));
1140        assert!(!simple_glob_match("{src,lib}/*.{ts,js}", "bin/util.js"));
1141        // Nested groups.
1142        assert!(simple_glob_match("*.{t{s,sx}}", "x.tsx"));
1143        assert!(simple_glob_match("*.{t{s,sx}}", "x.ts"));
1144        // Single alternative and empty alternative.
1145        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        // Unbalanced brace stays a literal.
1149        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        // 4 groups × 4 alts = 256 > cap; must stop at the cap, not hang.
1156        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        // Confirm new contents readable.
1179        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    // ── F7: JSON-escape auto-repair ──
1209
1210    #[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        // \r\n collapses to a single newline (ordered before \r / \n).
1216        assert_eq!(unescape_literal_controls(r"a\r\nb"), "a\nb");
1217        // Double backslash is NOT special-cased (Go Replacer semantics):
1218        // `\\n` → `\` + newline.
1219        assert_eq!(unescape_literal_controls(r"a\\nb"), "a\\\nb");
1220        // No escapes → unchanged.
1221        assert_eq!(unescape_literal_controls("plain"), "plain");
1222    }
1223
1224    #[test]
1225    fn resolve_edit_search_prefers_direct_match() {
1226        // Content contains the literal two-char sequence; direct match
1227        // wins and no repair fires.
1228        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"); // new_string unescaped too
1239        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        // replace_all=true accepts the multi-match.
1258        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                // Model emitted literal \n instead of a real newline.
1271                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        // Repair is silent: the success output must NOT surface a `repair`
1279        // field to the model (MiMoCode "success silent" policy).
1280        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        // Regression: a wide glob must NOT descend into node_modules/target,
1370        // whose contents previously ballooned a result list into the
1371        // megabytes and blew past the model's context window.
1372        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}