capo-agent 0.6.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::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};

use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};
use tokio::process::Command;
use tokio::time::timeout;

use crate::tools::ToolCtx;

const MAX_OUTPUT_BYTES: usize = 30 * 1024;
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;

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

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

impl Tool for BashTool {
    fn def(&self) -> ToolDef {
        ToolDef {
            name: "bash".to_string(),
            description: "Execute a shell command. Default timeout 120s, max 600s. Output capped at 30KB per stream.".to_string(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "command": { "type": "string", "description": "Shell command to run via `bash -lc`." },
                    "description": { "type": "string", "description": "Human-readable one-line summary." },
                    "timeout_ms": { "type": "integer", "description": "Override timeout in milliseconds (max 600000)." }
                },
                "required": ["command"]
            }),
        }
    }

    fn call(
        &self,
        args: Value,
        _ctx: &ToolContext,
    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
        let ctx = Arc::clone(&self.ctx);
        Box::pin(async move {
            let command = match args.get("command").and_then(|v| v.as_str()) {
                Some(c) if !c.is_empty() => c.to_string(),
                _ => return ToolResult::error("missing or empty 'command' argument"),
            };
            let requested_timeout = args
                .get("timeout_ms")
                .and_then(|v| v.as_u64())
                .unwrap_or(DEFAULT_TIMEOUT_MS)
                .min(MAX_TIMEOUT_MS);

            let started = Instant::now();
            let mut cmd = Command::new("bash");
            cmd.arg("-lc")
                .arg(&command)
                .current_dir(&ctx.cwd)
                .kill_on_drop(true);

            let fut = cmd.output();
            let output = tokio::select! {
                biased;
                _ = ctx.cancel_token.cancelled() => {
                    tracing::debug!(
                        target: "capo::bash",
                        "cancelled; up to {} bytes of queued output may be discarded",
                        MAX_OUTPUT_BYTES * 2,
                    );
                    return ToolResult::error("command cancelled by user");
                }
                res = timeout(Duration::from_millis(requested_timeout), fut) => match res {
                    Ok(Ok(out)) => out,
                    Ok(Err(e)) => return ToolResult::error(format!("failed to spawn bash: {e}")),
                    Err(_) => return ToolResult::error(format!("command timed out after {requested_timeout}ms")),
                },
            };

            let stdout = truncate(output.stdout, MAX_OUTPUT_BYTES);
            let stderr = truncate(output.stderr, MAX_OUTPUT_BYTES);
            let exit = output.status.code().unwrap_or(-1);
            let duration_ms = started.elapsed().as_millis() as u64;

            let body = format!(
                "exit={exit} duration_ms={duration_ms}\n--- stdout ---\n{}\n--- stderr ---\n{}",
                String::from_utf8_lossy(&stdout),
                String::from_utf8_lossy(&stderr),
            );
            ToolResult::text(body)
        })
    }
}

fn truncate(buf: Vec<u8>, max: usize) -> Vec<u8> {
    if buf.len() <= max {
        return buf;
    }

    let text = String::from_utf8_lossy(&buf);
    if text.len() <= max {
        return text.into_owned().into_bytes();
    }

    let keep = max / 2;
    let start_end = floor_char_boundary(&text, keep);
    let end_start = ceil_char_boundary(&text, text.len().saturating_sub(keep));
    let omitted = text
        .len()
        .saturating_sub(start_end + (text.len().saturating_sub(end_start)));

    let mut out = String::with_capacity(max + 64);
    out.push_str(&text[..start_end]);
    out.push_str(&format!("\n... ({omitted} bytes truncated) ...\n"));
    out.push_str(&text[end_start..]);
    out.into_bytes()
}

fn floor_char_boundary(text: &str, limit: usize) -> usize {
    if limit >= text.len() {
        return text.len();
    }

    let mut boundary = 0;
    for (idx, _) in text.char_indices() {
        if idx > limit {
            break;
        }
        boundary = idx;
    }
    boundary
}

fn ceil_char_boundary(text: &str, target: usize) -> usize {
    if target >= text.len() {
        return text.len();
    }

    for (idx, _) in text.char_indices() {
        if idx >= target {
            return idx;
        }
    }
    text.len()
}

#[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 runs_simple_command_and_returns_exit_zero() {
        let dir = tempdir().expect("tempdir");
        let tool = BashTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "command": "echo hello" }), &ToolContext::default())
            .await;
        let debug = format!("{result:?}");
        assert!(debug.contains("exit=0"), "missing exit=0: {debug}");
        assert!(debug.contains("hello"), "missing stdout hello: {debug}");
    }

    #[tokio::test]
    async fn captures_stderr_and_nonzero_exit() {
        let dir = tempdir().expect("tempdir");
        let tool = BashTool::new(test_ctx(dir.path()));
        let result = tool
            .call(
                json!({ "command": "echo problem >&2; exit 3" }),
                &ToolContext::default(),
            )
            .await;
        let debug = format!("{result:?}");
        assert!(debug.contains("exit=3"), "bad exit: {debug}");
        assert!(debug.contains("problem"), "missing stderr: {debug}");
    }

    #[tokio::test]
    async fn timeout_fires_and_reports_error() {
        let dir = tempdir().expect("tempdir");
        let tool = BashTool::new(test_ctx(dir.path()));
        let result = tool
            .call(
                json!({ "command": "sleep 5", "timeout_ms": 200 }),
                &ToolContext::default(),
            )
            .await;
        let debug = format!("{result:?}");
        assert!(debug.contains("timed out"), "got: {debug}");
    }

    #[tokio::test]
    async fn truncates_large_stdout() {
        let dir = tempdir().expect("tempdir");
        let tool = BashTool::new(test_ctx(dir.path()));
        let result = tool
            .call(
                json!({ "command": "yes | head -c 40000" }),
                &ToolContext::default(),
            )
            .await;
        let debug = format!("{result:?}");
        assert!(
            debug.contains("bytes truncated"),
            "expected truncation: {debug}"
        );
        assert!(debug.contains("exit=0"), "pipe exit should be 0: {debug}");
    }

    #[tokio::test]
    async fn cancellation_token_aborts_command() {
        let dir = tempdir().expect("tempdir");
        let (tx, _rx) = mpsc::channel(8);
        let ctx = Arc::new(ToolCtx::new(dir.path(), Arc::new(NoOpPermissionGate), tx));
        let cancel = ctx.cancel_token.clone();
        let tool = BashTool::new(Arc::clone(&ctx));

        let handle = tokio::spawn(async move {
            tool.call(json!({ "command": "sleep 10" }), &ToolContext::default())
                .await
        });
        tokio::time::sleep(Duration::from_millis(100)).await;
        cancel.cancel();

        let result = tokio::time::timeout(Duration::from_secs(2), handle)
            .await
            .expect("timeout")
            .expect("join");
        let debug = format!("{result:?}");
        assert!(debug.to_lowercase().contains("cancel"), "got: {debug}");
    }

    #[test]
    fn truncate_preserves_utf8_boundaries() {
        let input = "🙂中文é🙂中文é🙂中文é".repeat(200).into_bytes();
        let truncated = truncate(input, 128);
        let text = String::from_utf8(truncated).expect("valid utf8 after truncate");
        assert!(!text.contains('�'), "replacement char leaked: {text}");
        assert!(
            text.contains("bytes truncated"),
            "missing truncation marker: {text}"
        );
    }
}