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 agentic_search_config: None,
135 agentic_parse_config: None,
136 document_parser_config: None,
137 sandbox: None,
138 command_env: None,
139 document_parsers: None,
140 document_pipeline: None,
141 },
142 )
143 .await?;
144
145 if result.exit_code != 0 {
146 return Ok(format!("[Error: {}]", result.output));
147 }
148
149 let output = result.output.trim().to_string();
150 if output.len() > self.max_output_length {
151 Ok(format!(
152 "{}... [truncated {} bytes]",
153 &output[..self.max_output_length],
154 output.len() - self.max_output_length
155 ))
156 } else {
157 Ok(output)
158 }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn test_process_inline_pattern() {
168 let preprocessor = DefaultShellPreprocessor::new();
169 let matches = preprocessor.process_inline("Run !`echo hello` now");
170 assert_eq!(matches.len(), 1);
171 assert_eq!(matches[0].0, "!`echo hello`");
172 assert_eq!(matches[0].1, "echo hello");
173 }
174
175 #[test]
176 fn test_process_block_pattern() {
177 let preprocessor = DefaultShellPreprocessor::new();
178 let content = r#"```!
179ls -la
180```"#;
181 let matches = preprocessor.process_blocks(content);
182 assert_eq!(matches.len(), 1);
183 assert_eq!(matches[0].1, "ls -la");
184 }
185
186 #[test]
187 fn test_process_block_pattern_with_newlines() {
188 let preprocessor = DefaultShellPreprocessor::new();
189 let content = r#"```!
191ls -la
192```"#;
193 let matches = preprocessor.process_blocks(content);
194 assert_eq!(matches.len(), 1);
195 assert_eq!(matches[0].1, "ls -la");
196 }
197
198 #[test]
199 fn test_no_false_positives() {
200 let preprocessor = DefaultShellPreprocessor::new();
201 let matches = preprocessor.process_inline("echo $! and !`echo hi`");
203 assert_eq!(matches.len(), 1); assert_eq!(matches[0].1, "echo hi");
205 }
206
207 #[test]
208 fn test_process_inline_at_start_of_line() {
209 let preprocessor = DefaultShellPreprocessor::new();
210 let matches = preprocessor.process_inline("!`pwd`");
211 assert_eq!(matches.len(), 1);
212 assert_eq!(matches[0].1, "pwd");
213 }
214
215 #[test]
216 fn test_process_inline_multiple() {
217 let preprocessor = DefaultShellPreprocessor::new();
218 let matches = preprocessor.process_inline("First !`cmd1` then !`cmd2`");
219 assert_eq!(matches.len(), 2);
220 assert_eq!(matches[0].1, "cmd1");
221 assert_eq!(matches[1].1, "cmd2");
222 }
223
224 #[test]
225 fn test_process_inline_no_matches() {
226 let preprocessor = DefaultShellPreprocessor::new();
227 let matches = preprocessor.process_inline("No shell commands here");
228 assert!(matches.is_empty());
229 }
230
231 #[test]
232 fn test_process_inline_backtick_in_string() {
233 let preprocessor = DefaultShellPreprocessor::new();
234 let matches = preprocessor.process_inline("`echo hello`");
236 assert!(matches.is_empty());
237 }
238
239 #[test]
240 fn test_process_blocks_multiple() {
241 let preprocessor = DefaultShellPreprocessor::new();
242 let content = r#"```!
243ls -la
244```
245Some text
246```!
247pwd
248```"#;
249 let matches = preprocessor.process_blocks(content);
250 assert_eq!(matches.len(), 2);
251 assert_eq!(matches[0].1, "ls -la");
252 assert_eq!(matches[1].1, "pwd");
253 }
254
255 #[test]
256 fn test_process_blocks_no_matches() {
257 let preprocessor = DefaultShellPreprocessor::new();
258 let content = "```\nsome code\n```";
259 let matches = preprocessor.process_blocks(content);
260 assert!(matches.is_empty());
261 }
262
263 #[test]
264 fn test_process_blocks_empty_command() {
265 let preprocessor = DefaultShellPreprocessor::new();
266 let content = "```!\n```";
267 let matches = preprocessor.process_blocks(content);
268 assert!(matches.is_empty());
270 }
271
272 #[test]
273 fn test_default_shell_preprocessor_new() {
274 let preprocessor = DefaultShellPreprocessor::new();
275 assert_eq!(preprocessor.max_output_length, 10 * 1024);
276 }
277}