Skip to main content

soul_core/executor/
shell.rs

1//! Shell executor — runs tools as shell commands via VirtualExecutor.
2
3use std::sync::Arc;
4
5use tokio::sync::mpsc;
6
7use crate::error::{SoulError, SoulResult};
8use crate::tool::ToolOutput;
9use crate::types::ToolDefinition;
10use crate::vexec::VirtualExecutor;
11
12use super::ToolExecutor;
13
14/// Executes tools by running shell commands through a [`VirtualExecutor`].
15///
16/// The tool arguments should contain a `command` field with the shell command to run.
17/// On native platforms, use [`NativeExecutor`](crate::vexec::NativeExecutor).
18/// In WASM / tests, inject a [`MockExecutor`](crate::vexec::MockExecutor) or
19/// [`NoopExecutor`](crate::vexec::NoopExecutor).
20pub struct ShellExecutor {
21    exec: Arc<dyn VirtualExecutor>,
22    default_timeout_secs: u64,
23    cwd: Option<String>,
24}
25
26impl ShellExecutor {
27    pub fn new(exec: Arc<dyn VirtualExecutor>) -> Self {
28        Self {
29            exec,
30            default_timeout_secs: 120,
31            cwd: None,
32        }
33    }
34
35    /// Create a ShellExecutor backed by the native OS executor.
36    #[cfg(feature = "native")]
37    pub fn native() -> Self {
38        Self::new(Arc::new(crate::vexec::NativeExecutor::new()))
39    }
40
41    pub fn with_timeout(mut self, secs: u64) -> Self {
42        self.default_timeout_secs = secs;
43        self
44    }
45
46    pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
47        self.cwd = Some(cwd.into());
48        self
49    }
50}
51
52#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
53#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
54impl ToolExecutor for ShellExecutor {
55    async fn execute(
56        &self,
57        definition: &ToolDefinition,
58        _call_id: &str,
59        arguments: serde_json::Value,
60        _partial_tx: Option<mpsc::UnboundedSender<String>>,
61    ) -> SoulResult<ToolOutput> {
62        let command = arguments
63            .get("command")
64            .and_then(|v| v.as_str())
65            .ok_or_else(|| SoulError::ToolExecution {
66                tool_name: definition.name.clone(),
67                message: "Missing 'command' argument".into(),
68            })?;
69
70        let output = self
71            .exec
72            .exec_shell(command, self.default_timeout_secs, self.cwd.as_deref())
73            .await
74            .map_err(|e| SoulError::ToolExecution {
75                tool_name: definition.name.clone(),
76                message: format!("Failed to execute: {e}"),
77            })?;
78
79        if output.success() {
80            Ok(ToolOutput::success(output.stdout))
81        } else {
82            let content = if output.stderr.is_empty() {
83                format!("Exit code: {}\n{}", output.exit_code, output.stdout)
84            } else {
85                format!(
86                    "Exit code: {}\nstderr: {}\nstdout: {}",
87                    output.exit_code, output.stderr, output.stdout
88                )
89            };
90            Ok(ToolOutput::error(content))
91        }
92    }
93
94    fn executor_name(&self) -> &str {
95        "shell"
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::vexec::{ExecOutput, MockExecutor};
103    use serde_json::json;
104
105    fn test_def() -> ToolDefinition {
106        ToolDefinition {
107            name: "shell_test".into(),
108            description: "Test".into(),
109            input_schema: json!({"type": "object"}),
110        }
111    }
112
113    fn mock_ok(stdout: &str) -> Arc<dyn VirtualExecutor> {
114        Arc::new(MockExecutor::always_ok(stdout))
115    }
116
117    fn mock_fail(exit_code: i32) -> Arc<dyn VirtualExecutor> {
118        Arc::new(MockExecutor::new(vec![ExecOutput {
119            stdout: String::new(),
120            stderr: "error output".into(),
121            exit_code,
122        }]))
123    }
124
125    #[tokio::test]
126    async fn echo_command() {
127        let executor = ShellExecutor::new(mock_ok("hello\n"));
128        let result = executor
129            .execute(&test_def(), "c1", json!({"command": "echo hello"}), None)
130            .await
131            .unwrap();
132        assert_eq!(result.content.trim(), "hello");
133        assert!(!result.is_error);
134    }
135
136    #[tokio::test]
137    async fn missing_command_errors() {
138        let executor = ShellExecutor::new(mock_ok(""));
139        let result = executor
140            .execute(&test_def(), "c1", json!({"other": "value"}), None)
141            .await;
142        assert!(result.is_err());
143    }
144
145    #[tokio::test]
146    async fn failing_command() {
147        let executor = ShellExecutor::new(mock_fail(42));
148        let result = executor
149            .execute(&test_def(), "c1", json!({"command": "exit 42"}), None)
150            .await
151            .unwrap();
152        assert!(result.is_error);
153        assert!(result.content.contains("42"));
154    }
155
156    #[test]
157    fn executor_name() {
158        let executor = ShellExecutor::new(mock_ok(""));
159        assert_eq!(executor.executor_name(), "shell");
160    }
161
162    #[tokio::test]
163    async fn custom_timeout() {
164        let executor = ShellExecutor::new(mock_ok("fast")).with_timeout(1);
165        let result = executor
166            .execute(&test_def(), "c1", json!({"command": "echo fast"}), None)
167            .await
168            .unwrap();
169        assert!(!result.is_error);
170    }
171
172    #[cfg(feature = "native")]
173    #[tokio::test]
174    async fn native_echo() {
175        let executor = ShellExecutor::native();
176        let result = executor
177            .execute(&test_def(), "c1", json!({"command": "echo hello"}), None)
178            .await
179            .unwrap();
180        assert_eq!(result.content.trim(), "hello");
181        assert!(!result.is_error);
182    }
183}