1use 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
19const READ_LINES: usize = 2000;
22const READ_LINE_CHARS: usize = 2000;
23
24const GREP_LIMIT: usize = 100;
26
27pub 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
63pub 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 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 .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 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
126fn 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
160fn 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 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
185fn 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 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
240fn 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
281fn 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
293pub 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 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
327pub(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;