enact-core 0.0.2

Core agent runtime for Enact - Graph-Native AI agents
Documentation
//! Shell command execution tool

use crate::tool::Tool;
use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
use tokio::process::Command;

const SHELL_TIMEOUT_SECS: u64 = 60;
const MAX_OUTPUT_BYTES: usize = 1_048_576; // 1MB

/// Shell command execution tool
pub struct ShellTool;

impl ShellTool {
    pub fn new() -> Self {
        Self
    }
}

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

#[async_trait]
impl Tool for ShellTool {
    fn name(&self) -> &str {
        "shell"
    }

    fn description(&self) -> &str {
        "Execute a shell command in the workspace directory"
    }

    fn parameters_schema(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "The shell command to execute"
                },
                "timeout": {
                    "type": "integer",
                    "description": "Timeout in seconds (default: 60)",
                    "minimum": 1,
                    "maximum": 300
                }
            },
            "required": ["command"]
        })
    }

    fn requires_network(&self) -> bool {
        false // Shell can work offline, though commands might need network
    }

    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<serde_json::Value> {
        let command = args
            .get("command")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("Missing 'command' parameter"))?;

        let timeout_secs = args
            .get("timeout")
            .and_then(|v| v.as_u64())
            .unwrap_or(SHELL_TIMEOUT_SECS);

        let timeout = Duration::from_secs(timeout_secs.min(300));

        // Use sh -c for shell commands
        let output =
            tokio::time::timeout(timeout, Command::new("sh").arg("-c").arg(command).output())
                .await
                .map_err(|_| {
                    anyhow::anyhow!("Command timed out after {} seconds", timeout_secs)
                })??;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        // Truncate if too large
        let stdout = if stdout.len() > MAX_OUTPUT_BYTES {
            format!("{}... [truncated]", &stdout[..MAX_OUTPUT_BYTES])
        } else {
            stdout.to_string()
        };

        let stderr = if stderr.len() > MAX_OUTPUT_BYTES {
            format!("{}... [truncated]", &stderr[..MAX_OUTPUT_BYTES])
        } else {
            stderr.to_string()
        };

        Ok(json!({
            "success": output.status.success(),
            "stdout": stdout,
            "stderr": stderr,
            "exit_code": output.status.code(),
            "command": command
        }))
    }
}

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

    #[tokio::test]
    async fn test_shell_echo() {
        let tool = ShellTool::new();
        let result = tool
            .execute(json!({
                "command": "echo 'Hello World'"
            }))
            .await
            .unwrap();

        assert_eq!(result["success"], true);
        assert!(result["stdout"].as_str().unwrap().contains("Hello World"));
    }

    #[tokio::test]
    async fn test_shell_error() {
        let tool = ShellTool::new();
        let result = tool
            .execute(json!({
                "command": "exit 1"
            }))
            .await
            .unwrap();

        assert_eq!(result["success"], false);
        assert_eq!(result["exit_code"], 1);
    }

    #[tokio::test]
    async fn test_shell_timeout() {
        let tool = ShellTool::new();
        let result = tool
            .execute(json!({
                "command": "sleep 10",
                "timeout": 1
            }))
            .await;

        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("timed out"));
    }
}