Skip to main content

harness/tools/
mod.rs

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/// LLM-facing description of one tool. `input_schema` is a JSON Schema
19/// object the model uses to generate well-formed `tool_call.arguments`.
20/// Shared between providers — OpenAI wraps it in `{type:"function", function:{...}}`,
21/// future Anthropic client will pass it as Messages API `input_schema` verbatim.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ToolSpec {
24    pub name: String,
25    pub description: String,
26    /// JSON Schema for `tool_call.arguments`. Must be an `object`-typed schema.
27    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    /// Verbatim JSON argument text emitted by the model for this tool call.
36    ///
37    /// Populated when the provider streams raw argument deltas, such as
38    /// OpenAI-compatible `function.arguments` chunks. `None` for synthetic
39    /// invocations, provider-final parsed inputs, restored histories that
40    /// predate this field, or repaired inputs where the literal model text no
41    /// longer matches `input`.
42    #[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    /// Structured non-text attachments returned by the tool — currently
50    /// just images returned by MCP servers (e.g. a screenshot tool).
51    /// Empty Vec for native tools (`SandboxToolRuntime`) — none of them
52    /// produce non-text output today. Reuses `UserAttachment` rather
53    /// than introducing a parallel `ToolAttachment` enum because both
54    /// shapes are identical (image source); rename to `MediaAttachment`
55    /// if a future tool variant differs.
56    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    /// Rejected by policy before dispatch (e.g. a shell command on the
67    /// hard-deny list). The message tells the model why so it can adjust.
68    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    // Teaching: show a minimal valid example synthesized from the schema so
123    // the model sees the exact shape it should have produced.
124    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    /// Apply schema-guided input repair in place, returning the repairs made
205    /// (or `None` when the input is already clean / no schema matches).
206    ///
207    /// The default is a no-op; [`bounded::BoundedToolRuntime`] overrides it as
208    /// the single source of truth for repair. `agent_loop` calls this BEFORE
209    /// recording the invocation in history / events so the recorded arguments
210    /// match what the runtime ultimately executes; the wrapper re-applies it
211    /// during dispatch (idempotent) to also cover bypass callers.
212    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    /// Cancellation-aware variant of `invoke`. When `cancel` is fired
222    /// the runtime SHOULD abort the in-flight tool (e.g. SIGTERM the
223    /// shell subprocess for `bash`) and return `ToolRuntimeError::
224    /// Runtime("cancelled")`.
225    ///
226    /// The default implementation races `invoke` against cancellation. This
227    /// drops the invocation future when cancelled, which prevents in-process
228    /// work from continuing after the turn ends. Runtimes that start work in
229    /// an external system should override this and explicitly terminate it.
230    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                // Repair is silent: a successful edit reports only the result.
373                // The json-escape rescue is recorded for tracing, never
374                // surfaced to the model (MiMoCode "success silent" policy).
375                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/// Successful resolution of an edit's search/replace strings against file
454/// content. `repair` is `Some("json_escape_unwrapped")` when the match
455/// only succeeded after unescaping literal `\n` / `\t` / `\r` sequences —
456/// weak models frequently double-escape control characters when emitting
457/// `old_string` through JSON tool arguments.
458#[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/// Why an edit search failed to resolve. Callers build the user-facing
467/// message (each runtime embeds the path differently).
468#[derive(Debug, PartialEq)]
469pub enum EditSearchError {
470    /// `old_string` not in content, no escape rescue applicable.
471    NotFound,
472    /// `old_string` looked JSON-escaped, but the unescaped text is not in
473    /// the content either.
474    EscapedNotFound,
475    /// Direct match is ambiguous without `replace_all`.
476    Ambiguous { occurrences: usize },
477    /// Unescaped match is ambiguous without `replace_all`.
478    EscapedAmbiguous { occurrences: usize },
479}
480
481/// Resolve `old_string` against `content`, falling back to unescaping
482/// literal control sequences when the strict match fails. The direct
483/// match keeps our existing ambiguity guard; the escape fallback mirrors
484/// unescape both old and new strings, require a match, and reject
485/// multi-location matches without `replace_all`.
486pub 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
528/// Does the string contain literal (two-character) `\n` / `\t` / `\r`
529/// escape sequences?
530fn has_literal_escaped_controls(s: &str) -> bool {
531    s.contains("\\n") || s.contains("\\t") || s.contains("\\r")
532}
533
534/// Replace literal escape sequences with their control characters:
535/// `\r\n` → newline (checked first), then
536/// `\n` → newline, `\r` → CR, `\t` → tab. Anything else passes through —
537/// including `\\` — so `\\n` becomes `\` + newline, matching Go.
538fn 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    // Only ASCII subsequences were replaced; multi-byte UTF-8 passes
569    // through verbatim, so the result is valid UTF-8.
570    String::from_utf8(out).unwrap_or_else(|_| s.to_string())
571}
572
573/// Glob-match a pattern against a relative path string.
574/// Supports `*` (any chars except `/`), `**` (any chars including `/`), `?`,
575/// and `{a,b}` brace alternation (models emit `**/*.{ts,tsx}` reflexively —
576/// silently treating `{` as a literal made such patterns match nothing).
577/// Used by `MockToolRuntime` and by `fs_glob` for real-filesystem walks.
578pub fn simple_glob_match(pattern: &str, candidate: &str) -> bool {
579    if pattern.contains('{') {
580        // expand_braces returns fully-expanded patterns (no `{` groups left
581        // except literal unbalanced ones), so match each directly.
582        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
589/// Cap on patterns produced by brace expansion — a backstop against
590/// pathological nesting like `{a,b}{c,d}{e,f}…` multiplying without bound.
591const MAX_BRACE_EXPANSIONS: usize = 128;
592
593/// Expand the first balanced `{a,b,…}` group into one pattern per
594/// alternative, recursing so nested groups and multiple groups multiply
595/// out. A pattern without braces (or with an unbalanced `{`, kept as a
596/// literal) returns itself. Output is capped at [`MAX_BRACE_EXPANSIONS`].
597fn 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    // Find the matching close brace, tracking nesting depth.
603    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()]; // unbalanced → literal `{`
620    };
621    let prefix: String = chars[..open].iter().collect();
622    let suffix: String = chars[close + 1..].iter().collect();
623    // Split alternatives on top-level commas only (commas inside nested
624    // groups belong to the inner group).
625    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    // Translate the glob to a regex-lite pattern by walking byte-by-byte.
657    // `**` matches anything (including separators); `*` matches any chars
658    // except `/`; `?` matches a single non-`/` char; everything else is
659    // literal. Keep it simple: O(N*M) recursive descent.
660    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
710/// Directory names pruned before descent during a glob walk. These hold
711/// build artefacts / dependency trees that a name-pattern search almost
712/// never wants and that can balloon a result list into the megabytes —
713/// enough to blow past the model's context window when the list rides back
714/// into history. Pruned unconditionally (the walk never enters them), which
715/// also keeps the walk fast on large repos.
716pub 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
728/// Hard ceiling on paths returned by a single glob walk. Reaching it stops
729/// the walk early (rather than collecting the whole tree and trimming after)
730/// so a pathological pattern can't allocate an unbounded list before we cap
731/// it. Callers learn whether the cap was hit via [`fs_glob_bounded`].
732pub const MAX_FS_GLOB_RESULTS: usize = 2000;
733
734/// Output size cap before spilling to a temporary file. When a tool produces
735/// more bytes than this, runtimes write the full content to
736/// `/tmp/harness_out_<call_id>_<suffix>.txt` and return a preview with the
737/// path so the model can fetch the rest with the read tool if needed.
738pub const MAX_OUTPUT_BYTES: usize = 50_000;
739
740/// Tail bytes scanned for an error signature when bounding output. If the
741/// failure the model needs is in the last chunk, head-only truncation would
742/// drop it — so we detect it and keep a tail slice instead.
743const TAIL_SCAN_BYTES: usize = 2048;
744
745/// Case-insensitive substrings that mark a line worth preserving in the tail
746/// of a truncated output (mirrors MiMoCode's truncation heuristic).
747const ERROR_MARKERS: &[&str] = &[
748    "error",
749    "exception",
750    "failed",
751    "fatal",
752    "panic",
753    "traceback",
754    "exit code",
755];
756
757/// Compute the preview for an over-budget tool output. Returns `None` when
758/// `full` fits within [`MAX_OUTPUT_BYTES`] (caller uses it unchanged, no
759/// spill). Otherwise returns a preview the caller should surface AFTER
760/// writing `full` to `spill_path`:
761/// * if the tail carries an error signature, keep head (70% budget) AND tail
762///   (30%) so the failure survives the cut;
763/// * else keep head only.
764///
765/// The preview ends with a hint pointing at the spilled file.
766pub 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
778/// Error-aware head+tail clip for outputs that have NO spill file but blew
779/// past a hard ceiling — the catch-all in [`bounded::BoundedToolRuntime`] for
780/// tools (MCP, custom) that don't self-bound. Always returns a bounded
781/// string with a note (no file reference).
782pub 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
790/// Build the truncated body (no surrounding note): head+tail when the tail
791/// carries an error signature so the failure survives, else head only.
792fn 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
812/// Head-only clip with a note, for outputs that have NO spill file (read
813/// `content`, grep error text). Returns the string unchanged when within
814/// budget; otherwise clips at a char boundary near [`MAX_OUTPUT_BYTES`].
815pub 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
829/// Does the last [`TAIL_SCAN_BYTES`] of `s` contain an error marker?
830fn 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
839/// Collect whole lines from the front until adding the next would exceed
840/// `budget` bytes (counting the rejoining `\n`).
841fn 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
855/// Collect whole lines from the back until adding the next would exceed
856/// `budget` bytes; returned in original order.
857fn 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
872/// Walk `base_dir` recursively and return relative paths that match `pattern`.
873/// Skips hidden directories (`.git`, `.DS_Store`, etc.) unless the pattern
874/// explicitly starts with `.`, prunes dependency / build directories
875/// ([`FS_GLOB_IGNORED_DIRS`]), and caps the result count at
876/// [`MAX_FS_GLOB_RESULTS`]. Results are sorted lexicographically.
877/// Intended for use by production `ToolRuntime` implementations.
878///
879/// When the caller needs to know whether the cap was reached (e.g. to tell
880/// the model the list was truncated), use [`fs_glob_bounded`] — this wrapper
881/// discards that flag for the common case.
882pub fn fs_glob(pattern: &str, base_dir: &std::path::Path) -> Vec<String> {
883    fs_glob_bounded(pattern, base_dir).0
884}
885
886/// Like [`fs_glob`] but also reports whether the [`MAX_FS_GLOB_RESULTS`] cap
887/// was hit. A `true` second element means the returned list is a prefix of
888/// the full match set and the search should be narrowed.
889pub 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                // Prune dependency / build trees before descending so their
910                // (often enormous) contents are never read at all.
911                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
932/// Canonical specs for the built-in tool set (bash / read / write). Shared
933/// between `MockToolRuntime` (in-process tests) and the production
934/// `SandboxToolRuntime` (in `core` crate) so both report the same schema to
935/// the model. Adding a new built-in tool changes one place.
936///
937/// Note on `additionalProperties: false`: keeps the model from inventing
938/// fields. Required for some providers' strict tool-calling mode.
939pub 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        // ~60KB of innocuous lines, no error markers anywhere.
1166        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        // Head+tail mode: both the head AND the trailing error survive.
1189        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        // The exact shape models emit reflexively.
1213        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        // Multiple groups multiply out.
1217        assert!(simple_glob_match("{src,lib}/*.{ts,js}", "lib/util.js"));
1218        assert!(!simple_glob_match("{src,lib}/*.{ts,js}", "bin/util.js"));
1219        // Nested groups.
1220        assert!(simple_glob_match("*.{t{s,sx}}", "x.tsx"));
1221        assert!(simple_glob_match("*.{t{s,sx}}", "x.ts"));
1222        // Single alternative and empty alternative.
1223        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        // Unbalanced brace stays a literal.
1227        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        // 4 groups × 4 alts = 256 > cap; must stop at the cap, not hang.
1234        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        // Confirm new contents readable.
1258        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    // ── F7: JSON-escape auto-repair ──
1290
1291    #[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        // \r\n collapses to a single newline (ordered before \r / \n).
1297        assert_eq!(unescape_literal_controls(r"a\r\nb"), "a\nb");
1298        // Double backslash is NOT special-cased (Go Replacer semantics):
1299        // `\\n` → `\` + newline.
1300        assert_eq!(unescape_literal_controls(r"a\\nb"), "a\\\nb");
1301        // No escapes → unchanged.
1302        assert_eq!(unescape_literal_controls("plain"), "plain");
1303    }
1304
1305    #[test]
1306    fn resolve_edit_search_prefers_direct_match() {
1307        // Content contains the literal two-char sequence; direct match
1308        // wins and no repair fires.
1309        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"); // new_string unescaped too
1320        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        // replace_all=true accepts the multi-match.
1339        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                // Model emitted literal \n instead of a real newline.
1352                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        // Repair is silent: the success output must NOT surface a `repair`
1361        // field to the model (MiMoCode "success silent" policy).
1362        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        // Regression: a wide glob must NOT descend into node_modules/target,
1461        // whose contents previously ballooned a result list into the
1462        // megabytes and blew past the model's context window.
1463        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}