agnt 0.1.0

A dense, sync-first Rust agent engine — multi-backend inference, parallel tool dispatch, SQLite persistence, streaming
Documentation
use crate::tool::Tool;
use serde_json::{json, Value};
use std::fs;
use std::process::Command;

pub struct ReadFile;

const READ_FILE_MAX: usize = 256 * 1024;

impl Tool for ReadFile {
    fn name(&self) -> &str { "read_file" }
    fn description(&self) -> &str {
        "Read a UTF-8 text file and return its contents. Truncated at 256KB. Prefer this over 'shell cat' — it is deterministic and cheaper."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string", "description": "absolute or relative file path" }
            },
            "required": ["path"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        let path = args["path"].as_str().ok_or("missing path")?;
        let content = fs::read_to_string(path).map_err(|e| format!("read {}: {}", path, e))?;
        if content.len() <= READ_FILE_MAX {
            return Ok(content);
        }
        let mut cut = READ_FILE_MAX;
        while cut > 0 && !content.is_char_boundary(cut) {
            cut -= 1;
        }
        let mut out = content[..cut].to_string();
        out.push_str(&format!(
            "\n...(truncated at {} bytes; file is {} bytes total)",
            cut,
            content.len()
        ));
        Ok(out)
    }
}

pub struct EditFile;
impl Tool for EditFile {
    fn name(&self) -> &str { "edit_file" }
    fn description(&self) -> &str {
        "Targeted file edit. Replaces one exact occurrence of 'old' with 'new' in the file. Fails if 'old' is not found or appears more than once — in that case pass more surrounding context in 'old' to make it unique. Prefer this over write_file when changing a small part of an existing file."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string" },
                "old":  { "type": "string", "description": "exact text to find (must be unique in the file)" },
                "new":  { "type": "string", "description": "replacement text" }
            },
            "required": ["path", "old", "new"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        let path = args["path"].as_str().ok_or("missing path")?;
        let old = args["old"].as_str().ok_or("missing old")?;
        let new_s = args["new"].as_str().ok_or("missing new")?;
        if old.is_empty() {
            return Err("'old' must not be empty".into());
        }
        let content = fs::read_to_string(path).map_err(|e| format!("read {}: {}", path, e))?;
        let count = content.matches(old).count();
        if count == 0 {
            return Err(format!("'old' not found in {}", path));
        }
        if count > 1 {
            return Err(format!(
                "'old' appears {} times in {}; pass more surrounding context to make it unique",
                count, path
            ));
        }
        let updated = content.replacen(old, new_s, 1);
        fs::write(path, &updated).map_err(|e| format!("write {}: {}", path, e))?;
        Ok(format!(
            "edited {} ({} bytes → {} bytes)",
            path,
            content.len(),
            updated.len()
        ))
    }
}

pub struct WriteFile;
impl Tool for WriteFile {
    fn name(&self) -> &str { "write_file" }
    fn description(&self) -> &str { "Write UTF-8 content to a file, creating or overwriting it." }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string" },
                "content": { "type": "string" }
            },
            "required": ["path", "content"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        let path = args["path"].as_str().ok_or("missing path")?;
        let content = args["content"].as_str().ok_or("missing content")?;
        fs::write(path, content).map_err(|e| format!("write {}: {}", path, e))?;
        Ok(format!("wrote {} bytes to {}", content.len(), path))
    }
}

pub struct ListDir;
impl Tool for ListDir {
    fn name(&self) -> &str { "list_dir" }
    fn description(&self) -> &str {
        "List a directory. One entry per line as 'TYPE NAME' where TYPE is F (file), D (dir), or L (symlink)."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string" }
            },
            "required": ["path"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        let path = args["path"].as_str().ok_or("missing path")?;
        let mut out = String::new();
        for entry in fs::read_dir(path).map_err(|e| format!("read_dir {}: {}", path, e))? {
            let e = entry.map_err(|e| e.to_string())?;
            let ft = e.file_type().map_err(|e| e.to_string())?;
            let tag = if ft.is_dir() { 'D' } else if ft.is_symlink() { 'L' } else { 'F' };
            out.push_str(&format!("{} {}\n", tag, e.file_name().to_string_lossy()));
        }
        Ok(out)
    }
}

pub struct Shell {
    pub unsafe_mode: bool,
}

const SHELL_DENYLIST: &[&str] = &[
    "rm -rf /",
    "rm -rf ~",
    "rm -rf ..",
    "sudo ",
    "dd if=",
    "mkfs",
    ":(){:|:&};:",
    "> /dev/sda",
    "> /dev/nvme",
    "chmod -r 777 /",
    "chown -r",
    "shutdown",
    "reboot",
    "halt",
    "init 0",
    "init 6",
];

fn is_dangerous(cmd: &str) -> bool {
    let lower = cmd.to_lowercase();
    SHELL_DENYLIST.iter().any(|p| lower.contains(p))
}

impl Tool for Shell {
    fn name(&self) -> &str { "shell" }
    fn description(&self) -> &str {
        "Run a shell command via 'sh -c'. Use as a LAST RESORT when no specialized tool fits — for tasks like 'git status', 'cargo build', or one-off pipelines. For reading files use read_file; for writing use write_file; for edits use edit_file; for searching contents use grep; for finding files use glob; for listing a directory use list_dir; for HTTP use fetch. Dangerous commands (rm -rf /, sudo, dd, mkfs, shutdown) are refused."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "cmd": { "type": "string", "description": "command line to execute" }
            },
            "required": ["cmd"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        let cmd = args["cmd"].as_str().ok_or("missing cmd")?;
        if !self.unsafe_mode && is_dangerous(cmd) {
            return Err(format!("refused dangerous command: {}", cmd));
        }
        let out = Command::new("sh")
            .arg("-c")
            .arg(cmd)
            .output()
            .map_err(|e| format!("spawn: {}", e))?;
        let status = out
            .status
            .code()
            .map(|c| c.to_string())
            .unwrap_or_else(|| "signal".into());
        Ok(format!(
            "exit: {}\n--- stdout ---\n{}--- stderr ---\n{}",
            status,
            String::from_utf8_lossy(&out.stdout),
            String::from_utf8_lossy(&out.stderr),
        ))
    }
}

pub struct Glob;
impl Tool for Glob {
    fn name(&self) -> &str { "glob" }
    fn description(&self) -> &str {
        "Find files matching a shell-style glob pattern (e.g. 'src/**/*.rs', '**/Cargo.toml'). Returns one path per line. Prefer this over 'shell find' — it is faster, portable across OSes, and has no command-injection surface."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "pattern": { "type": "string", "description": "glob pattern" }
            },
            "required": ["pattern"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        let pattern = args["pattern"].as_str().ok_or("missing pattern")?;
        let mut out = String::new();
        let mut count = 0usize;
        for entry in glob::glob(pattern).map_err(|e| format!("glob: {}", e))? {
            if let Ok(p) = entry {
                out.push_str(&p.to_string_lossy());
                out.push('\n');
                count += 1;
                if count >= 2000 {
                    out.push_str("(truncated at 2000)\n");
                    break;
                }
            }
        }
        if out.is_empty() {
            Ok("(no matches)".into())
        } else {
            Ok(out)
        }
    }
}

pub struct Grep;
impl Tool for Grep {
    fn name(&self) -> &str { "grep" }
    fn description(&self) -> &str {
        "Search text files under a directory for a regex pattern. Returns 'path:line:text' per match. Optional 'ext' filter (e.g. 'rs', 'md'). Prefer this over 'shell grep' — it is native, typically under 1ms for a source tree, and avoids quoting pitfalls."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "pattern": { "type": "string", "description": "regex pattern" },
                "path":    { "type": "string", "description": "root directory to walk" },
                "ext":     { "type": "string", "description": "optional file extension filter without dot" }
            },
            "required": ["pattern", "path"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        let pattern = args["pattern"].as_str().ok_or("missing pattern")?;
        let path = args["path"].as_str().ok_or("missing path")?;
        let ext = args["ext"].as_str();
        let re = regex::Regex::new(pattern).map_err(|e| format!("regex: {}", e))?;
        let mut out = String::new();
        let mut count = 0usize;
        for entry in walkdir::WalkDir::new(path)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            if !entry.file_type().is_file() { continue; }
            if let Some(e) = ext {
                if entry.path().extension().and_then(|s| s.to_str()) != Some(e) { continue; }
            }
            let content = match fs::read_to_string(entry.path()) {
                Ok(c) => c,
                Err(_) => continue,
            };
            for (i, line) in content.lines().enumerate() {
                if re.is_match(line) {
                    out.push_str(&format!("{}:{}:{}\n", entry.path().display(), i + 1, line));
                    count += 1;
                    if count >= 500 {
                        out.push_str("(truncated at 500 matches)\n");
                        return Ok(out);
                    }
                }
            }
        }
        if out.is_empty() {
            Ok("(no matches)".into())
        } else {
            Ok(out)
        }
    }
}

pub struct Fetch;
impl Tool for Fetch {
    fn name(&self) -> &str { "fetch" }
    fn description(&self) -> &str {
        "HTTP GET a URL and return the response body (first 50KB). Use for fetching docs or raw text."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "url": { "type": "string" }
            },
            "required": ["url"]
        })
    }
    fn call(&self, args: Value) -> Result<String, String> {
        use std::io::Read;
        let url = args["url"].as_str().ok_or("missing url")?;
        let resp = crate::http::agent()
            .get(url)
            .call()
            .map_err(|e| format!("fetch: {}", e))?;
        let status = resp.status();
        let mut body = String::new();
        resp.into_reader()
            .take(50_000)
            .read_to_string(&mut body)
            .map_err(|e| format!("read: {}", e))?;
        Ok(format!("HTTP {}\n{}", status, body))
    }
}