pi-agent 1.0.0

Agent runtime with tool calling (Rust port of @earendil-works/pi-agent-core). Streaming agent loop, permission policy, built-in tools (read/write/edit/bash/grep/glob/web_fetch/todo).
Documentation
use async_trait::async_trait;
use ignore::WalkBuilder;
use serde_json::{json, Value};
use std::fs;

use crate::types::{AgentTool, AgentToolResult};

pub struct GrepTool;

#[async_trait]
impl AgentTool for GrepTool {
    fn name(&self) -> &str {
        "grep"
    }
    fn description(&self) -> &str {
        "Search file contents under a directory for a fixed substring. Honors .gitignore by default."
    }
    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "pattern": {"type": "string", "description": "Substring to search for"},
                "path": {"type": "string", "description": "Directory to search (default: cwd)"},
                "max_matches": {"type": "integer", "default": 200}
            },
            "required": ["pattern"]
        })
    }
    async fn execute(&self, _id: &str, args: Value) -> Result<AgentToolResult, String> {
        let pattern = args
            .get("pattern")
            .and_then(|v| v.as_str())
            .ok_or("missing 'pattern'")?
            .to_string();
        let path = args
            .get("path")
            .and_then(|v| v.as_str())
            .unwrap_or(".")
            .to_string();
        let max = args
            .get("max_matches")
            .and_then(|v| v.as_u64())
            .unwrap_or(200) as usize;

        let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
            let mut buf = String::new();
            let mut hits = 0usize;
            let walker = WalkBuilder::new(&path).follow_links(false).build();
            for entry in walker.flatten() {
                if hits >= max {
                    buf.push_str(&format!("... (truncated at {max} matches)\n"));
                    break;
                }
                let p = entry.path();
                if !p.is_file() {
                    continue;
                }
                let text = match fs::read_to_string(p) {
                    Ok(t) => t,
                    Err(_) => continue, // skip binary or unreadable
                };
                for (i, line) in text.lines().enumerate() {
                    if line.contains(&pattern) {
                        buf.push_str(&format!("{}:{}:{}\n", p.display(), i + 1, line));
                        hits += 1;
                        if hits >= max {
                            break;
                        }
                    }
                }
            }
            Ok(buf)
        })
        .await
        .map_err(|e| e.to_string())??;

        Ok(AgentToolResult::text(if result.is_empty() {
            "(no matches)".to_string()
        } else {
            result
        }))
    }
}