metrale_bench/benchmarks/agentic/
agent_tools.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The six tools the agent loop offers (`bash`, `read`, `write`,
4//! `edit`, `glob`, `grep`) and their implementations.
5//!
6//! Owner: bench, agentic.
7//! Invariants:
8//! - Every file path a tool call names goes through `resolve`.
9//! - A `bash` call's time limit is at most `AgentConfig::command_timeout`.
10
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use anyhow::{Result, anyhow, bail};
15use serde_json::{Value, json};
16
17use super::{AgentConfig, resolve, run_shell};
18
19/// 2026-09-26: `read` returns up to `READ_LINES` lines by default and cuts each
20/// line at `READ_LINE_CHARS` characters.
21const READ_LINES: usize = 2000;
22const READ_LINE_CHARS: usize = 2000;
23
24/// 2026-09-26: `grep` stops at this many matches and says so.
25const GREP_LIMIT: usize = 100;
26
27/// 2026-09-26: The tool definitions sent with every request.
28pub fn tool_schema() -> Value {
29    let s = |d: &str| json!({"type": "string", "description": d});
30    let n = |d: &str| json!({"type": "integer", "description": d});
31    let p = s("The absolute path to the file. A relative one is also accepted.");
32    json!([
33        {"type": "function", "function": {"name": "bash",
34            "description": "Executes a given bash command, ensuring proper handling and security measures.\n\nUsage notes:\n- The command argument is required.\n- You can specify an optional timeout in milliseconds.\n- A long-running process MUST be started detached with its output redirected to a file (`setsid cmd > /tmp/log 2>&1 &`); a background process that inherits this tool's pipes holds them open and the command is reported as timed out.\n- Long output is truncated in the middle and the number of elided characters is reported.",
35            "parameters": {"type": "object", "required": ["command"], "properties": {
36                "command": s("The command to execute"), "timeout": n("Optional timeout in milliseconds")}}}},
37        {"type": "function", "function": {"name": "read",
38            "description": "Reads a file, or lists a directory.\n\n- The offset parameter is the line number to start from (1-indexed).\n- Contents are returned with each line prefixed by its line number as `<line>: <content>`.\n- For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\n- Any line longer than 2000 characters is truncated.",
39            "parameters": {"type": "object", "required": ["filePath"], "properties": {
40                "filePath": p, "offset": n("The line number to start reading from (1-indexed)"),
41                "limit": n("The maximum number of lines to read (defaults to 2000)")}}}},
42        {"type": "function", "function": {"name": "write",
43            "description": "Writes a file to the local filesystem.\n\nUsage:\n- This tool will overwrite the existing file if there is one at the provided path.\n- Parent directories are created for you.\n- NEVER proactively create documentation files (*.md) or README files.",
44            "parameters": {"type": "object", "required": ["filePath", "content"], "properties": {
45                "filePath": p, "content": s("The content to write to the file")}}}},
46        {"type": "function", "function": {"name": "edit",
47            "description": "Performs exact string replacements in files.\n\nUsage:\n- When editing text from read tool output, preserve the exact indentation as it appears AFTER the line number prefix. The prefix format is `1: `; never include any part of it in oldString.\n- The edit will FAIL if `oldString` is not found in the file.\n- The edit will FAIL if `oldString` is found multiple times. Either provide a larger string with more surrounding context to make it unique, or use `replaceAll` to change every instance.",
48            "parameters": {"type": "object", "required": ["filePath", "oldString", "newString"], "properties": {
49                "filePath": p, "oldString": s("The text to replace"), "newString": s("The text to replace it with"),
50                "replaceAll": {"type": "boolean", "description": "Replace all occurrences (default false)"}}}}},
51        {"type": "function", "function": {"name": "glob",
52            "description": "- Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.rs\" or \"src/**/*.rs\"\n- Returns matching file paths\n- Use this tool when you need to find files by name patterns",
53            "parameters": {"type": "object", "required": ["pattern"], "properties": {
54                "pattern": s("The glob pattern to match files against")}}}},
55        {"type": "function", "function": {"name": "grep",
56            "description": "- Searches file contents and returns file paths and line numbers with matching lines\n- `pattern` is matched as a literal substring, NOT a regular expression; for regex use the bash tool with `grep -E`\n- Stops after 100 matches",
57            "parameters": {"type": "object", "required": ["pattern"], "properties": {
58                "pattern": s("The literal text to search for in file contents"),
59                "include": s("File pattern to include in the search (e.g. \"*.rs\")")}}}}
60    ])
61}
62
63/// 2026-09-26: Run one model-issued tool call. Each `bash` command is appended
64/// to `commands`, which `score::followed_directions` reads.
65pub async fn execute(
66    cfg: &AgentConfig,
67    call: &crate::http::ToolCall,
68    commands: &mut Vec<String>,
69) -> Result<String> {
70    let raw = match call.arguments.is_empty() {
71        true => "{}",
72        false => &call.arguments,
73    };
74    let args: Value =
75        serde_json::from_str(raw).map_err(|e| anyhow!("arguments were not valid JSON: {e}"))?;
76    let arg = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);
77    let need = |k: &str| arg(k).ok_or_else(|| anyhow!("{} needs a `{k}`", call.name));
78    // 2026-09-26: The schema names it `filePath`; `path` is accepted too.
79    let file = || match arg("filePath").or_else(|| arg("path")) {
80        Some(rel) => resolve(&cfg.sandbox, &rel),
81        None => bail!("{} needs a `filePath`", call.name),
82    };
83    match call.name.as_str() {
84        "bash" => {
85            let cmd = need("command")?;
86            commands.push(cmd.clone());
87            let limit = args
88                .get("timeout")
89                .and_then(Value::as_u64)
90                .map(Duration::from_millis)
91                // 2026-09-26: A model-supplied timeout may shorten the ceiling,
92                // never raise it.
93                .map_or(cfg.command_timeout, |t| t.min(cfg.command_timeout));
94            run_shell(cfg, &cmd, limit).await
95        }
96        "write" => {
97            let (path, content) = (file()?, arg("content").unwrap_or_default());
98            std::fs::create_dir_all(path.parent().unwrap_or(&cfg.sandbox))?;
99            std::fs::write(&path, &content)?;
100            Ok(format!(
101                "wrote {} ({} bytes)",
102                rel(cfg, &path),
103                content.len()
104            ))
105        }
106        "read" => read_tool(&args, file()?),
107        "edit" => {
108            let path = file()?;
109            let hits = edit_tool(&args, &path, &need("oldString")?, &need("newString")?)?;
110            // 2026-09-26: Sandbox-relative, like `write`, so the result holds no
111            // absolute path that differs between runs or boxes.
112            Ok(format!(
113                "replaced {hits} occurrence(s) in {}",
114                rel(cfg, &path)
115            ))
116        }
117        "glob" => {
118            let hits: Vec<String> = matching(cfg, Some(&need("pattern")?)).collect();
119            Ok(or_empty(hits.join("\n")))
120        }
121        "grep" => Ok(grep_tool(cfg, &need("pattern")?, arg("include").as_deref())),
122        other => bail!("unknown tool {other}; available: bash, read, write, edit, glob, grep"),
123    }
124}
125
126/// 2026-09-26: `read`: a directory is listed one entry per line with `/` after
127/// subdirectories; a file is returned as numbered lines from `offset`.
128fn read_tool(args: &Value, path: PathBuf) -> Result<String> {
129    if path.is_dir() {
130        let mut names: Vec<String> = std::fs::read_dir(&path)?
131            .flatten()
132            .map(|e| {
133                let slash = if e.path().is_dir() { "/" } else { "" };
134                e.file_name().to_string_lossy().into_owned() + slash
135            })
136            .collect();
137        names.sort();
138        return Ok(or_empty(names.join("\n")));
139    }
140    let text = read_capped(&path).map_err(|e| anyhow!("{}: {e}", path.display()))?;
141    let num = |k: &str| args.get(k).and_then(Value::as_u64).map(|n| n as usize);
142    let offset = num("offset").unwrap_or(1).max(1);
143    let body: Vec<String> = text
144        .lines()
145        .enumerate()
146        .skip(offset - 1)
147        .take(num("limit").unwrap_or(READ_LINES))
148        .map(|(i, l)| match l.chars().count() > READ_LINE_CHARS {
149            true => format!(
150                "{}: {}... (line truncated)",
151                i + 1,
152                head(l, READ_LINE_CHARS)
153            ),
154            false => format!("{}: {l}", i + 1),
155        })
156        .collect();
157    Ok(or_empty(body.join("\n")))
158}
159
160/// 2026-09-26: Exact string replacement. Fails when `oldString` is empty or
161/// absent, or matches more than once without `replaceAll`; the multiple-match
162/// error says how to retry.
163fn edit_tool(args: &Value, path: &Path, old: &str, new: &str) -> Result<usize> {
164    if old.is_empty() {
165        bail!("oldString must not be empty");
166    }
167    let all = args.get("replaceAll").and_then(Value::as_bool) == Some(true);
168    // 2026-09-26: Not capped like [`read_capped`]: this text is written back, so
169    // reading a prefix would truncate the file on disk.
170    let text = std::fs::read_to_string(path).map_err(|e| anyhow!("{}: {e}", path.display()))?;
171    let hits = text.matches(old).count();
172    if hits == 0 {
173        bail!("oldString not found in content");
174    }
175    if hits > 1 && !all {
176        bail!(
177            "Found multiple matches for oldString ({hits}). Provide more surrounding lines in \
178             oldString to identify the correct match, or set replaceAll to change every instance."
179        );
180    }
181    std::fs::write(path, text.replacen(old, new, if all { hits } else { 1 }))?;
182    Ok(hits)
183}
184
185/// 2026-09-26: Read at most `READ_LINES * READ_LINE_CHARS` bytes of a file,
186/// appending a note when the cap was reached. Nothing bounds the size of a file
187/// in the sandbox.
188fn read_capped(path: &Path) -> std::io::Result<String> {
189    use std::io::{Error, ErrorKind, Read};
190    const CAP: usize = READ_LINES * READ_LINE_CHARS;
191    let mut buf = Vec::new();
192    std::fs::File::open(path)?
193        .take(CAP as u64)
194        .read_to_end(&mut buf)?;
195    let capped = buf.len() == CAP;
196    let mut text = match String::from_utf8(buf) {
197        Ok(text) => text,
198        // 2026-09-26: Non-UTF-8 is an error (`grep` then skips the file), except
199        // for a multi-byte character the cap itself cut in half.
200        Err(e) if capped && e.as_bytes().len() - e.utf8_error().valid_up_to() < 4 => {
201            let whole = e.utf8_error().valid_up_to();
202            String::from_utf8_lossy(&e.as_bytes()[..whole]).into_owned()
203        }
204        Err(_) => {
205            return Err(Error::new(
206                ErrorKind::InvalidData,
207                "stream did not contain valid UTF-8",
208            ));
209        }
210    };
211    if capped {
212        text.push_str("\n… (file truncated at this point) …");
213    }
214    Ok(text)
215}
216
217fn grep_tool(cfg: &AgentConfig, pattern: &str, include: Option<&str>) -> String {
218    let mut out = Vec::new();
219    for rel in matching(cfg, include) {
220        let Ok(text) = read_capped(&cfg.sandbox.join(&rel)) else {
221            continue;
222        };
223        for (i, line) in text
224            .lines()
225            .enumerate()
226            .filter(|(_, l)| l.contains(pattern))
227        {
228            out.push(format!("{rel}:{}: {}", i + 1, head(line.trim(), 300)));
229            if out.len() >= GREP_LIMIT {
230                return format!("{}\n(stopped at {GREP_LIMIT} matches)", out.join("\n"));
231            }
232        }
233    }
234    match out.is_empty() {
235        true => "No files found".into(),
236        false => format!("Found {} matches\n{}", out.len(), out.join("\n")),
237    }
238}
239
240/// 2026-09-26: Sandbox-relative paths of every regular file, sorted and
241/// optionally filtered by a glob. `target` and `.git` directories are skipped.
242///
243/// Symlinks are skipped: [`entry.file_type()`][std::fs::DirEntry::file_type]
244/// reports the link itself. Following them lets a few `ln -s . x` links make
245/// the walk grow exponentially, and no timeout applies to `glob` or `grep`.
246fn matching(cfg: &AgentConfig, pattern: Option<&str>) -> impl Iterator<Item = String> {
247    let (mut stack, mut files) = (vec![cfg.sandbox.clone()], Vec::new());
248    while let Some(dir) = stack.pop() {
249        let Ok(entries) = std::fs::read_dir(&dir) else {
250            continue;
251        };
252        for entry in entries.flatten() {
253            if matches!(entry.file_name().to_str(), Some("target") | Some(".git")) {
254                continue;
255            }
256            let Ok(kind) = entry.file_type() else {
257                continue;
258            };
259            let path = entry.path();
260            match (kind.is_dir(), kind.is_file()) {
261                (true, _) => stack.push(path),
262                (_, true) => files.push(rel(cfg, &path)),
263                _ => {}
264            }
265        }
266    }
267    files.sort();
268    let pattern = pattern.map(str::to_string);
269    files.into_iter().filter(move |f| {
270        pattern
271            .as_ref()
272            .is_none_or(|p| glob_match(p.as_bytes(), f.as_bytes()))
273    })
274}
275
276fn rel(cfg: &AgentConfig, path: &Path) -> String {
277    let path = path.strip_prefix(&cfg.sandbox).unwrap_or(path);
278    path.to_string_lossy().into_owned()
279}
280
281/// 2026-09-26: "No files found" in place of an empty result.
282fn or_empty(s: String) -> String {
283    match s.is_empty() {
284        true => "No files found".into(),
285        false => s,
286    }
287}
288
289fn head(s: &str, n: usize) -> String {
290    s.chars().take(n).collect()
291}
292
293/// 2026-09-26: `*` (within one path segment), `**` (across segments), `?`. A
294/// pattern with no `/` is matched against the basename.
295pub fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
296    if !pattern.contains(&b'/')
297        && let Some(cut) = text.iter().rposition(|c| *c == b'/')
298    {
299        return glob_match(pattern, &text[cut + 1..]);
300    }
301    matches_from(pattern, text)
302}
303
304fn matches_from(pattern: &[u8], text: &[u8]) -> bool {
305    let Some(&head) = pattern.first() else {
306        return text.is_empty();
307    };
308    if head == b'*' {
309        let deep = pattern.get(1) == Some(&b'*');
310        let mut rest = &pattern[if deep { 2 } else { 1 }..];
311        // 2026-09-26: `**/` also matches zero directories, so `**/*.rs` finds
312        // `main.rs`.
313        if deep && rest.first() == Some(&b'/') {
314            rest = &rest[1..];
315        }
316        return matches_from(rest, text)
317            || (0..text.len())
318                .take_while(|i| deep || text[*i] != b'/')
319                .any(|i| matches_from(rest, &text[i + 1..]));
320    }
321    match text.first() {
322        Some(c) if head == b'?' || head == *c => matches_from(&pattern[1..], &text[1..]),
323        _ => false,
324    }
325}
326
327/// 2026-09-26: True when the turn has no parsed tool call but its text contains
328/// `<tool_call>` or `<function=`, the opening tags of the qwen3_coder call
329/// format. Opening tags only, so a block the parser could not close still
330/// counts.
331pub(super) fn emitted_unparsed_call(outcome: &crate::http::ChatOutcome) -> bool {
332    outcome.tool_calls.is_empty()
333        && ["<tool_call>", "<function="]
334            .iter()
335            .any(|m| outcome.text.contains(m))
336}
337
338#[cfg(test)]
339#[path = "agent_tools_tests.rs"]
340mod tests;