a3s_code_core/skills/
preprocessor.rs1use crate::error::Result;
17use crate::tools::{ToolExecutor, ToolResult};
18use async_trait::async_trait;
19use std::path::Path;
20use std::sync::Arc;
21
22#[async_trait]
24pub trait ShellPreprocessor: Send + Sync {
25 async fn process(
27 &self,
28 content: &str,
29 workspace: &Path,
30 executor: &Arc<ToolExecutor>,
31 ) -> Result<String>;
32}
33
34pub struct DefaultShellPreprocessor {
36 max_output_length: usize,
38}
39
40impl DefaultShellPreprocessor {
41 pub fn new() -> Self {
42 Self {
43 max_output_length: 10 * 1024, }
45 }
46
47 fn process_inline(&self, content: &str) -> Vec<(String, String)> {
49 let mut results = Vec::new();
50 let re = regex::Regex::new(r"(?:^|\s)(!`([^`]+)`)").unwrap();
53
54 for cap in re.captures_iter(content) {
55 let full_match = cap.get(1).unwrap().as_str();
56 let command = cap.get(2).unwrap().as_str().trim();
57 results.push((full_match.to_string(), command.to_string()));
58 }
59
60 results
61 }
62
63 fn process_blocks(&self, content: &str) -> Vec<(String, String)> {
65 let mut results = Vec::new();
66 let re = regex::Regex::new(r"```!\s*\n?([\s\S]*?)\n?```").unwrap();
68
69 for cap in re.captures_iter(content) {
70 let full_match = cap.get(0).unwrap().as_str();
71 let command = cap.get(1).unwrap().as_str().trim();
72 if !command.is_empty() {
73 results.push((full_match.to_string(), command.to_string()));
74 }
75 }
76
77 results
78 }
79}
80
81impl Default for DefaultShellPreprocessor {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87#[async_trait]
88impl ShellPreprocessor for DefaultShellPreprocessor {
89 async fn process(
90 &self,
91 content: &str,
92 workspace: &Path,
93 executor: &Arc<ToolExecutor>,
94 ) -> Result<String> {
95 let mut result = content.to_string();
96
97 for (pattern, command) in self.process_blocks(content) {
99 let output = self.execute_command(&command, workspace, executor).await?;
100 result = result.replace(&pattern, &output);
101 }
102
103 if result.contains("!`") {
106 for (pattern, command) in self.process_inline(&result) {
107 let output = self.execute_command(&command, workspace, executor).await?;
108 result = result.replace(&pattern, &output);
109 }
110 }
111
112 Ok(result)
113 }
114}
115
116impl DefaultShellPreprocessor {
117 async fn execute_command(
118 &self,
119 command: &str,
120 workspace: &Path,
121 executor: &Arc<ToolExecutor>,
122 ) -> Result<String> {
123 let args = serde_json::json!({ "command": command });
124 let result: ToolResult = executor
125 .execute_with_context(
126 "bash",
127 &args,
128 &crate::tools::ToolContext {
129 workspace: workspace.to_path_buf(),
130 session_id: None,
131 event_tx: None,
132 agent_event_tx: None,
133 search_config: None,
134 sandbox: None,
135 command_env: None,
136 },
137 )
138 .await?;
139
140 if result.exit_code != 0 {
141 return Ok(format!("[Error: {}]", result.output));
142 }
143
144 let output = result.output.trim().to_string();
145 if output.len() > self.max_output_length {
146 Ok(format!(
147 "{}... [truncated {} bytes]",
148 &output[..self.max_output_length],
149 output.len() - self.max_output_length
150 ))
151 } else {
152 Ok(output)
153 }
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn test_process_inline_pattern() {
163 let preprocessor = DefaultShellPreprocessor::new();
164 let matches = preprocessor.process_inline("Run !`echo hello` now");
165 assert_eq!(matches.len(), 1);
166 assert_eq!(matches[0].0, "!`echo hello`");
167 assert_eq!(matches[0].1, "echo hello");
168 }
169
170 #[test]
171 fn test_process_block_pattern() {
172 let preprocessor = DefaultShellPreprocessor::new();
173 let content = r#"```!
174ls -la
175```"#;
176 let matches = preprocessor.process_blocks(content);
177 assert_eq!(matches.len(), 1);
178 assert_eq!(matches[0].1, "ls -la");
179 }
180
181 #[test]
182 fn test_process_block_pattern_with_newlines() {
183 let preprocessor = DefaultShellPreprocessor::new();
184 let content = r#"```!
186ls -la
187```"#;
188 let matches = preprocessor.process_blocks(content);
189 assert_eq!(matches.len(), 1);
190 assert_eq!(matches[0].1, "ls -la");
191 }
192
193 #[test]
194 fn test_no_false_positives() {
195 let preprocessor = DefaultShellPreprocessor::new();
196 let matches = preprocessor.process_inline("echo $! and !`echo hi`");
198 assert_eq!(matches.len(), 1); assert_eq!(matches[0].1, "echo hi");
200 }
201
202 #[test]
203 fn test_process_inline_at_start_of_line() {
204 let preprocessor = DefaultShellPreprocessor::new();
205 let matches = preprocessor.process_inline("!`pwd`");
206 assert_eq!(matches.len(), 1);
207 assert_eq!(matches[0].1, "pwd");
208 }
209
210 #[test]
211 fn test_process_inline_multiple() {
212 let preprocessor = DefaultShellPreprocessor::new();
213 let matches = preprocessor.process_inline("First !`cmd1` then !`cmd2`");
214 assert_eq!(matches.len(), 2);
215 assert_eq!(matches[0].1, "cmd1");
216 assert_eq!(matches[1].1, "cmd2");
217 }
218
219 #[test]
220 fn test_process_inline_no_matches() {
221 let preprocessor = DefaultShellPreprocessor::new();
222 let matches = preprocessor.process_inline("No shell commands here");
223 assert!(matches.is_empty());
224 }
225
226 #[test]
227 fn test_process_inline_backtick_in_string() {
228 let preprocessor = DefaultShellPreprocessor::new();
229 let matches = preprocessor.process_inline("`echo hello`");
231 assert!(matches.is_empty());
232 }
233
234 #[test]
235 fn test_process_blocks_multiple() {
236 let preprocessor = DefaultShellPreprocessor::new();
237 let content = r#"```!
238ls -la
239```
240Some text
241```!
242pwd
243```"#;
244 let matches = preprocessor.process_blocks(content);
245 assert_eq!(matches.len(), 2);
246 assert_eq!(matches[0].1, "ls -la");
247 assert_eq!(matches[1].1, "pwd");
248 }
249
250 #[test]
251 fn test_process_blocks_no_matches() {
252 let preprocessor = DefaultShellPreprocessor::new();
253 let content = "```\nsome code\n```";
254 let matches = preprocessor.process_blocks(content);
255 assert!(matches.is_empty());
256 }
257
258 #[test]
259 fn test_process_blocks_empty_command() {
260 let preprocessor = DefaultShellPreprocessor::new();
261 let content = "```!\n```";
262 let matches = preprocessor.process_blocks(content);
263 assert!(matches.is_empty());
265 }
266
267 #[test]
268 fn test_default_shell_preprocessor_new() {
269 let preprocessor = DefaultShellPreprocessor::new();
270 assert_eq!(preprocessor.max_output_length, 10 * 1024);
271 }
272}