capo-agent 0.5.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;

use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};

use crate::tools::ToolCtx;

pub struct ReadTool {
    ctx: Arc<ToolCtx>,
}

impl ReadTool {
    pub fn new(ctx: Arc<ToolCtx>) -> Self {
        Self { ctx }
    }
}

impl Tool for ReadTool {
    fn def(&self) -> ToolDef {
        ToolDef {
            name: "read".to_string(),
            description: "Read the contents of a UTF-8 text file from disk.".to_string(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "path": { "type": "string", "description": "Path to file (absolute or cwd-relative)." },
                    "offset": { "type": "integer", "description": "1-based starting line (optional)." },
                    "limit":  { "type": "integer", "description": "Number of lines to read (default 2000)." }
                },
                "required": ["path"]
            }),
        }
    }

    fn call(
        &self,
        args: Value,
        _ctx: &ToolContext,
    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
        const MAX_BYTES: u64 = 2 * 1024 * 1024;

        let ctx = Arc::clone(&self.ctx);
        Box::pin(async move {
            let path = match args.get("path").and_then(|v| v.as_str()) {
                Some(p) => PathBuf::from(p),
                None => return ToolResult::error("missing 'path' argument"),
            };
            let abs = if path.is_absolute() {
                path.clone()
            } else {
                ctx.cwd.join(&path)
            };

            let metadata = match tokio::fs::metadata(&abs).await {
                Ok(m) => m,
                Err(e) => {
                    return ToolResult::error(format!("failed to stat {}: {e}", abs.display()))
                }
            };
            if metadata.len() > MAX_BYTES {
                return ToolResult::error(format!(
                    "file {} too large ({} bytes > {} byte cap)",
                    abs.display(),
                    metadata.len(),
                    MAX_BYTES
                ));
            }

            let bytes = match tokio::fs::read(&abs).await {
                Ok(b) => b,
                Err(e) => {
                    return ToolResult::error(format!("failed to read {}: {e}", abs.display()))
                }
            };
            let text = match String::from_utf8(bytes) {
                Ok(s) => s,
                Err(_) => {
                    return ToolResult::error(format!(
                        "file {} appears to be binary (non-UTF-8); use `bash` + `head`/`xxd` instead",
                        abs.display()
                    ));
                }
            };

            let offset = args
                .get("offset")
                .and_then(|v| v.as_u64())
                .map(|n| n.saturating_sub(1) as usize)
                .unwrap_or(0);
            let limit = args
                .get("limit")
                .and_then(|v| v.as_u64())
                .map(|n| n as usize)
                .unwrap_or(2000);
            let sliced = text
                .split_inclusive('\n')
                .skip(offset)
                .take(limit)
                .collect::<String>();

            let canonical = tokio::fs::canonicalize(&abs)
                .await
                .unwrap_or_else(|_| abs.clone());
            ctx.mark_read(&canonical).await;
            ToolResult::text(sliced)
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::permissions::NoOpPermissionGate;
    use tempfile::tempdir;
    use tokio::sync::mpsc;

    fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
        let (tx, _rx) = mpsc::channel(8);
        Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
    }

    #[tokio::test]
    async fn reads_existing_file_and_records_in_read_set() {
        let dir = tempdir().expect("tempdir");
        let file = dir.path().join("hello.txt");
        tokio::fs::write(&file, "hello, world\n")
            .await
            .expect("write");

        let ctx = test_ctx(dir.path());
        let tool = ReadTool::new(Arc::clone(&ctx));
        let result = tool
            .call(json!({ "path": "hello.txt" }), &ToolContext::default())
            .await;

        let debug = format!("{result:?}");
        assert!(
            debug.contains("hello, world"),
            "unexpected ToolResult: {debug}"
        );

        let canonical = tokio::fs::canonicalize(&file).await.expect("canonicalize");
        assert!(ctx.has_been_read(&canonical).await);
    }

    #[tokio::test]
    async fn rejects_file_larger_than_2mb() {
        let dir = tempdir().expect("tempdir");
        let file = dir.path().join("big.bin");
        let payload: Vec<u8> = std::iter::repeat_n(b'x', 2_500_000).collect();
        tokio::fs::write(&file, &payload).await.expect("write");

        let ctx = test_ctx(dir.path());
        let tool = ReadTool::new(Arc::clone(&ctx));
        let result = tool
            .call(json!({ "path": "big.bin" }), &ToolContext::default())
            .await;

        let debug = format!("{result:?}");
        assert!(debug.to_lowercase().contains("too large"), "got: {debug}");
    }

    #[tokio::test]
    async fn rejects_binary_file() {
        let dir = tempdir().expect("tempdir");
        let file = dir.path().join("pic.bin");
        tokio::fs::write(&file, [0xff_u8, 0xfe, 0xfd, 0xfc, 0x00, 0x01, 0x02])
            .await
            .expect("write");

        let ctx = test_ctx(dir.path());
        let tool = ReadTool::new(Arc::clone(&ctx));
        let result = tool
            .call(json!({ "path": "pic.bin" }), &ToolContext::default())
            .await;

        let debug = format!("{result:?}");
        assert!(debug.to_lowercase().contains("binary"), "got: {debug}");
    }

    #[tokio::test]
    async fn errors_on_missing_file() {
        let dir = tempdir().expect("tempdir");
        let ctx = test_ctx(dir.path());
        let tool = ReadTool::new(Arc::clone(&ctx));
        let result = tool
            .call(
                json!({ "path": "does_not_exist.txt" }),
                &ToolContext::default(),
            )
            .await;

        let debug = format!("{result:?}");
        assert!(
            debug.to_lowercase().contains("failed to stat"),
            "got: {debug}"
        );
    }

    #[tokio::test]
    async fn respects_offset_and_limit() {
        let dir = tempdir().expect("tempdir");
        let file = dir.path().join("lines.txt");
        let body: String = (1..=10).map(|n| format!("line{n}\n")).collect();
        tokio::fs::write(&file, body).await.expect("write");

        let ctx = test_ctx(dir.path());
        let tool = ReadTool::new(Arc::clone(&ctx));
        let result = tool
            .call(
                json!({ "path": "lines.txt", "offset": 3, "limit": 2 }),
                &ToolContext::default(),
            )
            .await;

        let debug = format!("{result:?}");
        assert!(debug.contains("line3"), "missing line3: {debug}");
        assert!(debug.contains("line4"), "missing line4: {debug}");
        assert!(!debug.contains("line5"), "unexpected line5 leaked: {debug}");
    }

    #[tokio::test]
    async fn preserves_crlf_and_trailing_newline() {
        let dir = tempdir().expect("tempdir");
        let file = dir.path().join("windows.txt");
        tokio::fs::write(&file, b"line1\r\nline2\r\n")
            .await
            .expect("write");

        let ctx = test_ctx(dir.path());
        let tool = ReadTool::new(Arc::clone(&ctx));
        let result = tool
            .call(
                json!({ "path": "windows.txt", "offset": 1, "limit": 2 }),
                &ToolContext::default(),
            )
            .await;

        let text = result.as_text().unwrap_or_default();
        assert_eq!(text, "line1\r\nline2\r\n");
    }

    #[tokio::test]
    async fn offset_zero_equals_offset_one_documented_behavior() {
        let dir = tempdir().expect("tempdir");
        let file = dir.path().join("lines.txt");
        tokio::fs::write(&file, "a\nb\nc\n").await.expect("write");
        let ctx = test_ctx(dir.path());
        let tool = ReadTool::new(Arc::clone(&ctx));

        let r0 = tool
            .call(
                json!({"path":"lines.txt","offset":0,"limit":2}),
                &ToolContext::default(),
            )
            .await;
        let r1 = tool
            .call(
                json!({"path":"lines.txt","offset":1,"limit":2}),
                &ToolContext::default(),
            )
            .await;

        assert_eq!(format!("{r0:?}"), format!("{r1:?}"));
    }
}