soul_core/executor/
shell.rs1use 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
14pub 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 #[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}