llama-cpp-v3-agent-sdk 0.1.7

Agentic tool-use loop on top of llama-cpp-v3 — local LLM agents with built-in tools
Documentation
use crate::error::AgentError;
use crate::tool::{Tool, ToolResult};
use std::path::PathBuf;
use std::process::Command;

/// Execute shell commands.
///
/// Output is captured and truncated to a configurable maximum length,
/// keeping the tail (so error messages at the end are preserved).
pub struct BashTool {
    /// Working directory for commands. Defaults to current directory.
    pub working_dir: PathBuf,
    /// Maximum output length in characters.
    pub max_output_chars: usize,
}

impl BashTool {
    pub fn new() -> Self {
        Self {
            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
            max_output_chars: 8192,
        }
    }

    pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
        self.working_dir = dir;
        self
    }
}

impl Default for BashTool {
    fn default() -> Self {
        Self::new()
    }
}

impl Tool for BashTool {
    fn name(&self) -> &str {
        "bash"
    }

    fn description(&self) -> &str {
        "Execute a shell command and return its output (stdout + stderr). \
         Output is truncated to keep the tail on long output."
    }

    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "The shell command to execute"
                }
            },
            "required": ["command"]
        })
    }

    fn execute(&self, args: &serde_json::Value) -> Result<ToolResult, AgentError> {
        let command = args["command"]
            .as_str()
            .ok_or_else(|| AgentError::Tool {
                tool: "bash".to_string(),
                message: "Missing 'command' argument".to_string(),
            })?;

        let output = if cfg!(windows) {
            Command::new("cmd")
                .args(["/C", command])
                .current_dir(&self.working_dir)
                .output()
        } else {
            Command::new("sh")
                .args(["-c", command])
                .current_dir(&self.working_dir)
                .output()
        };

        match output {
            Ok(output) => {
                let stdout = String::from_utf8_lossy(&output.stdout);
                let stderr = String::from_utf8_lossy(&output.stderr);
                let mut combined = String::new();

                if !stdout.is_empty() {
                    combined.push_str(&stdout);
                }
                if !stderr.is_empty() {
                    if !combined.is_empty() {
                        combined.push('\n');
                    }
                    combined.push_str("[stderr]\n");
                    combined.push_str(&stderr);
                }

                // Truncate keeping the tail
                if combined.len() > self.max_output_chars {
                    let skip = combined.len() - self.max_output_chars;
                    combined = format!(
                        "[...truncated {} chars...]\n{}",
                        skip,
                        &combined[skip..]
                    );
                }

                let exit_code = output.status.code().unwrap_or(-1);
                if exit_code != 0 {
                    combined.push_str(&format!("\n[exit code: {}]", exit_code));
                }

                Ok(if output.status.success() {
                    ToolResult::ok(combined)
                } else {
                    ToolResult::err(combined)
                })
            }
            Err(e) => Ok(ToolResult::err(format!("Failed to execute command: {}", e))),
        }
    }

    fn requires_permission(&self) -> bool {
        true
    }

    fn is_dangerous(&self, args: &serde_json::Value) -> bool {
        if let Some(cmd) = args["command"].as_str() {
            let cmd_lower = cmd.to_lowercase();
            // Common dangerous patterns
            cmd_lower.contains("rm -rf")
                || cmd_lower.contains("sudo")
                || cmd_lower.contains("curl") && cmd_lower.contains("| bash")
                || cmd_lower.contains("curl") && cmd_lower.contains("| sh")
                || cmd_lower.contains("format")
                || cmd_lower.contains("mkfs")
                || cmd_lower.contains("dd if=")
        } else {
            false
        }
    }
}