phi-kernel-tools 0.2.0

Kernel tools for phi-agent — file system, multi-agent, and shell tools
Documentation
use std::process::Stdio;
use std::time::Duration;

use agent_base::{AgentResult, Content, Tool, ToolContext};
use async_trait::async_trait;
use serde_json::{Value, json};

/// Local shell command execution tool.
///
/// Executes arbitrary commands via `sh -c`, with support for timeout,
/// cancellation, and working directory.
pub struct LocalShellTool {
    timeout_ms: u64,
}

impl LocalShellTool {
    pub fn new(timeout_ms: u64) -> Self {
        Self { timeout_ms }
    }
}

fn format_result(
    command: &str,
    stdout: &str,
    stderr: &str,
    exit_code: Option<i32>,
    timed_out: bool,
) -> String {
    let stdout = stdout.trim();
    let stderr = stderr.trim();

    if timed_out {
        return format!(
            "[Command Timed Out]\ncommand: {}\nstdout:\n{}\nstderr:\n{}",
            command,
            if stdout.is_empty() { "(empty)" } else { stdout },
            if stderr.is_empty() { "(empty)" } else { stderr },
        );
    }

    match exit_code {
        Some(0) => match (stdout.is_empty(), stderr.is_empty()) {
            (true, true) => "Command executed successfully with no output.".to_string(),
            (false, true) => stdout.to_string(),
            (true, false) => format!("stderr:\n{}", stderr),
            (false, false) => format!("stdout:\n{}\n\nstderr:\n{}", stdout, stderr),
        },
        Some(code) => format!(
            "[Command Failed (exit code: {})]\ncommand: {}\nstdout:\n{}\nstderr:\n{}",
            code,
            command,
            if stdout.is_empty() { "(empty)" } else { stdout },
            if stderr.is_empty() { "(empty)" } else { stderr },
        ),
        None => format!(
            "[Command Terminated]\ncommand: {}\nstdout:\n{}\nstderr:\n{}",
            command,
            if stdout.is_empty() { "(empty)" } else { stdout },
            if stderr.is_empty() { "(empty)" } else { stderr },
        ),
    }
}

#[async_trait]
impl Tool for LocalShellTool {
    fn name(&self) -> &'static str {
        "execute_command"
    }

    fn description(&self) -> &'static str {
        "Execute a shell command locally. Use for file operations, code compilation, Git operations, system info queries, etc. For commands that may produce large output, consider limiting lines (e.g. journalctl -n 50, grep ... | head -n 30)."
    }

    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "The shell command to execute. For commands that may produce large output, consider limiting lines: cat large files with | tail -n 30, find / ls -R with | head -n 50, grep over large scope with | head -n 30."
                },
                "working_dir": {
                    "type": "string",
                    "description": "Working directory. Uses the current directory if not specified."
                }
            },
            "required": ["command"]
        })
    }

    fn metadata(&self) -> agent_base::ToolMetadata {
        agent_base::ToolMetadata {
            name: self.name().to_string(),
            description: "Execute a shell command locally. Use for file operations, code compilation, Git operations, system info queries, etc.".to_string(),
            origin: "phi-kernel-tools".to_string(),
            version: env!("CARGO_PKG_VERSION").to_string(),
            requirements: vec![],
        }
    }

    async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
        let command = args
            .get("command")
            .and_then(Value::as_str)
            .unwrap_or("")
            .trim()
            .to_string();

        if command.is_empty() {
            return Ok(vec![Content::text(
                "[Error]: No command provided.".to_string(),
            )]);
        }

        tracing::info!(command = %command, timeout_ms = self.timeout_ms, "execute_command start");

        let working_dir = args.get("working_dir").and_then(Value::as_str);

        let mut cmd = tokio::process::Command::new("sh");
        cmd.arg("-c")
            .arg(&command)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .stdin(Stdio::null())
            .kill_on_drop(true);

        if let Some(dir) = working_dir {
            cmd.current_dir(dir);
        }

        // spawn + timeout + kill pattern: explicitly kill child process on timeout
        let child = match cmd.spawn() {
            Ok(c) => c,
            Err(e) => {
                tracing::error!(error = %e, command = %command, "execute_command: spawn failed");
                return Ok(vec![Content::text(format!(
                    "[Error]: Command execution failed: {}",
                    e
                ))]);
            }
        };

        let pid = child.id();
        let sleep = tokio::time::sleep(Duration::from_millis(self.timeout_ms));
        tokio::pin!(sleep);

        let output = tokio::select! {
            result = child.wait_with_output() => {
                match result {
                    Ok(output) => {
                        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
                        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
                        let exit_code = output.status.code();

                        tracing::info!(
                            command = %command,
                            exit_code = exit_code,
                            stdout_len = stdout.len(),
                            stderr_len = stderr.len(),
                            "execute_command: done"
                        );

                        let summary = format_result(&command, &stdout, &stderr, exit_code, false);
                        Ok(vec![Content::text(summary)])
                    }
                    Err(e) => {
                        tracing::error!(error = %e, command = %command, "execute_command: wait failed");
                        Ok(vec![Content::text(format!(
                            "[Error]: Command execution failed: {}",
                            e
                        ))])
                    }
                }
            }
            _ = &mut sleep => {
                // Timeout — kill the child process by pid (child has been moved by wait_with_output)
                if let Some(pid) = pid {
                    let _ = tokio::process::Command::new("kill")
                        .arg("-9")
                        .arg(pid.to_string())
                        .stdout(Stdio::null())
                        .stderr(Stdio::null())
                        .status()
                        .await;
                }
                tracing::warn!(command = %command, timeout_ms = self.timeout_ms, "execute_command: timed out and killed");
                Ok(vec![Content::text(format!(
                    "[Command Timed Out after {}ms]\ncommand: {}",
                    self.timeout_ms, command
                ))])
            }
        };

        output
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use agent_base::tool::content_text;

    #[test]
    fn test_format_result_success() {
        let result = format_result("echo hello", "hello", "", Some(0), false);
        assert_eq!(result, "hello");
    }

    #[test]
    fn test_format_result_stderr_only() {
        let result = format_result("cmd", "", "error output", Some(0), false);
        assert_eq!(result, "stderr:\nerror output");
    }

    #[test]
    fn test_format_result_stdout_and_stderr() {
        let result = format_result("cmd", "out", "err", Some(0), false);
        assert_eq!(result, "stdout:\nout\n\nstderr:\nerr");
    }

    #[test]
    fn test_format_result_terminated() {
        let result = format_result("cmd", "", "sigterm", None, false);
        assert!(result.contains("Command Terminated"));
    }

    #[test]
    fn test_format_result_no_output() {
        let result = format_result("true", "", "", Some(0), false);
        assert!(result.contains("no output"));
    }

    #[test]
    fn test_format_result_failure() {
        let result = format_result("false", "", "error", Some(1), false);
        assert!(result.contains("Command Failed"));
        assert!(result.contains("exit code: 1"));
    }

    #[test]
    fn test_format_result_timeout() {
        let result = format_result("sleep 100", "", "", None, true);
        assert!(result.contains("Command Timed Out"));
    }

    #[test]
    fn test_name() {
        let tool = LocalShellTool::new(30000);
        assert_eq!(tool.name(), "execute_command");
    }

    #[test]
    fn test_definition() {
        let tool = LocalShellTool::new(30000);
        assert_eq!(tool.name(), "execute_command");
        assert!(tool.description().contains("shell"));
        let schema = tool.schema();
        assert_eq!(schema["type"], "object");
        let required = schema["required"].as_array().unwrap();
        assert!(required.contains(&json!("command")));
    }

    #[test]
    fn test_metadata() {
        let tool = LocalShellTool::new(30000);
        let meta = tool.metadata();
        assert_eq!(meta.name, "execute_command");
        assert_eq!(meta.origin, "phi-kernel-tools");
        assert_eq!(meta.version, env!("CARGO_PKG_VERSION"));
        assert!(meta.description.contains("shell"));
        assert!(meta.requirements.is_empty());
    }

    #[tokio::test]
    async fn test_call_echo() {
        let tool = LocalShellTool::new(30000);
        let result = tool
            .call(&json!({"command": "echo hello"}), &ToolContext::for_test())
            .await
            .unwrap();
        assert!(content_text(&result).contains("hello"));
    }

    #[tokio::test]
    async fn test_call_empty_command() {
        let tool = LocalShellTool::new(30000);
        let result = tool
            .call(&json!({}), &ToolContext::for_test())
            .await
            .unwrap();
        assert!(content_text(&result).contains("No command provided"));
    }

    #[tokio::test]
    async fn test_call_failing_command() {
        let tool = LocalShellTool::new(30000);
        let result = tool
            .call(&json!({"command": "exit 3"}), &ToolContext::for_test())
            .await
            .unwrap();
        assert!(content_text(&result).contains("exit code: 3"));
    }

    #[tokio::test]
    async fn test_call_working_dir() {
        let tool = LocalShellTool::new(30000);
        let result = tool
            .call(
                &json!({"command": "pwd", "working_dir": "/"}),
                &ToolContext::for_test(),
            )
            .await
            .unwrap();
        let text = content_text(&result);
        assert!(!text.contains("[Error]"));
        assert!(text.contains('/'));
    }

    #[tokio::test]
    async fn test_call_timeout() {
        let tool = LocalShellTool::new(50);
        let result = tool
            .call(&json!({"command": "sleep 30"}), &ToolContext::for_test())
            .await
            .unwrap();
        assert!(content_text(&result).contains("Timed Out"));
    }
}