Skip to main content

bamboo_tools/tools/
bash_output.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use regex::Regex;
4use serde::Deserialize;
5use serde_json::json;
6
7use super::bash_runtime;
8
9#[derive(Debug, Deserialize)]
10struct BashOutputArgs {
11    bash_id: String,
12    #[serde(default)]
13    cursor: Option<usize>,
14    #[serde(default)]
15    filter: Option<String>,
16}
17
18pub struct BashOutputTool;
19
20impl BashOutputTool {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl Default for BashOutputTool {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32#[async_trait]
33impl Tool for BashOutputTool {
34    fn name(&self) -> &str {
35        "BashOutput"
36    }
37
38    fn description(&self) -> &str {
39        "Retrieve incremental output from a running or completed background Bash shell. Use the returned cursor to continue reading without replaying earlier lines."
40    }
41
42    fn classify(&self, _args: &serde_json::Value) -> ToolClass {
43        ToolClass::READONLY_PARALLEL
44    }
45
46    fn parameters_schema(&self) -> serde_json::Value {
47        json!({
48            "type": "object",
49            "properties": {
50                "bash_id": {
51                    "type": "string",
52                    "description": "The ID of the background shell to retrieve output from"
53                },
54                "filter": {
55                    "type": "string",
56                    "description": "Optional regular expression to filter output lines"
57                },
58                "cursor": {
59                    "type": "number",
60                    "description": "Read output starting from this cursor (0 for beginning)"
61                }
62            },
63            "required": ["bash_id"],
64            "additionalProperties": false
65        })
66    }
67
68    async fn invoke(
69        &self,
70        args: serde_json::Value,
71        _ctx: ToolCtx,
72    ) -> Result<ToolOutcome, ToolError> {
73        let parsed: BashOutputArgs = serde_json::from_value(args)
74            .map_err(|e| ToolError::InvalidArguments(format!("Invalid BashOutput args: {}", e)))?;
75
76        let shell = bash_runtime::get_shell(parsed.bash_id.trim())
77            .filter(|shell| !shell.is_model_polled())
78            .ok_or_else(|| {
79                ToolError::Execution(format!("Background shell '{}' not found", parsed.bash_id))
80            })?;
81
82        let regex = parsed
83            .filter
84            .as_ref()
85            .map(|value| {
86                Regex::new(value).map_err(|e| {
87                    ToolError::InvalidArguments(format!("Invalid filter regex: {}", e))
88                })
89            })
90            .transpose()?;
91
92        let cursor = parsed.cursor.unwrap_or(0);
93        let (lines, next_cursor, dropped_lines) =
94            shell.read_output_since(cursor, regex.as_ref()).await;
95        let status = shell.status();
96        let exit_code = shell.exit_code().await;
97
98        Ok(ToolOutcome::Completed(ToolResult {
99            success: true,
100            result: json!({
101                "bash_id": parsed.bash_id,
102                "status": status,
103                "exit_code": exit_code,
104                "next_cursor": next_cursor,
105                "dropped_lines": dropped_lines,
106                "output": lines.join("\n"),
107            })
108            .to_string(),
109            display_preference: Some("Collapsible".to_string()),
110            images: Vec::new(),
111        }))
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::tools::bash::BashTool;
119    use serde_json::Value;
120    use tokio::time::{sleep, Duration};
121
122    #[cfg(target_os = "windows")]
123    fn background_command() -> &'static str {
124        "echo alpha && echo beta"
125    }
126
127    #[cfg(not(target_os = "windows"))]
128    fn background_command() -> &'static str {
129        "printf 'alpha\\n'; printf 'beta\\n'"
130    }
131
132    #[cfg(target_os = "windows")]
133    fn invalid_utf8_background_command() -> String {
134        let shell = bamboo_infrastructure::process::preferred_bash_shell();
135        if shell.arg == "-lc" {
136            "printf '\\377\\n'".to_string()
137        } else {
138            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardOutput().Write($bytes,0,$bytes.Length)\"".to_string()
139        }
140    }
141
142    #[cfg(not(target_os = "windows"))]
143    fn invalid_utf8_background_command() -> String {
144        "printf '\\377\\n'".to_string()
145    }
146
147    async fn spawn_background_shell_id() -> String {
148        let bash = BashTool::new();
149        let out = bash
150            .invoke(
151                json!({
152                    "command": background_command(),
153                    "run_in_background": true
154                }),
155                ToolCtx::none("t"),
156            )
157            .await
158            .unwrap();
159        let ToolOutcome::Completed(result) = out else {
160            panic!("expected Completed")
161        };
162
163        let payload: Value = serde_json::from_str(&result.result).unwrap();
164        payload["bash_id"].as_str().unwrap().to_string()
165    }
166
167    async fn spawn_background_shell_id_for_command(command: String) -> String {
168        let bash = BashTool::new();
169        let out = bash
170            .invoke(
171                json!({
172                    "command": command,
173                    "run_in_background": true
174                }),
175                ToolCtx::none("t"),
176            )
177            .await
178            .unwrap();
179        let ToolOutcome::Completed(result) = out else {
180            panic!("expected Completed")
181        };
182
183        let payload: Value = serde_json::from_str(&result.result).unwrap();
184        payload["bash_id"].as_str().unwrap().to_string()
185    }
186
187    async fn wait_until_completed(shell_id: &str) {
188        let shell = super::bash_runtime::get_shell(shell_id).unwrap();
189        for _ in 0..100 {
190            if shell.status() == "completed" {
191                return;
192            }
193            sleep(Duration::from_millis(10)).await;
194        }
195        panic!("background shell did not complete in time");
196    }
197
198    #[tokio::test]
199    async fn bash_output_reads_incrementally() {
200        let shell_id = spawn_background_shell_id().await;
201        wait_until_completed(&shell_id).await;
202
203        let output_tool = BashOutputTool::new();
204        let first_out = output_tool
205            .invoke(json!({ "bash_id": shell_id }), ToolCtx::none("t"))
206            .await
207            .unwrap();
208        let ToolOutcome::Completed(first) = first_out else {
209            panic!("expected Completed")
210        };
211        let first_payload: Value = serde_json::from_str(&first.result).unwrap();
212        let first_output = first_payload["output"].as_str().unwrap_or_default();
213        let next_cursor = first_payload["next_cursor"].as_u64().unwrap_or(0);
214        assert!(first_output.contains("alpha"));
215        assert!(first_output.contains("beta"));
216
217        let second_out = output_tool
218            .invoke(
219                json!({ "bash_id": shell_id, "cursor": next_cursor }),
220                ToolCtx::none("t"),
221            )
222            .await
223            .unwrap();
224        let ToolOutcome::Completed(second) = second_out else {
225            panic!("expected Completed")
226        };
227        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
228        assert_eq!(second_payload["output"], "");
229    }
230
231    #[tokio::test]
232    async fn bash_output_filter_consumes_unmatched_lines() {
233        let shell_id = spawn_background_shell_id().await;
234        wait_until_completed(&shell_id).await;
235
236        let output_tool = BashOutputTool::new();
237        let filtered_out = output_tool
238            .invoke(
239                json!({
240                    "bash_id": shell_id,
241                    "filter": "alpha"
242                }),
243                ToolCtx::none("t"),
244            )
245            .await
246            .unwrap();
247        let ToolOutcome::Completed(filtered) = filtered_out else {
248            panic!("expected Completed")
249        };
250        let filtered_payload: Value = serde_json::from_str(&filtered.result).unwrap();
251        let next_cursor = filtered_payload["next_cursor"].as_u64().unwrap_or(0);
252        assert!(filtered_payload["output"]
253            .as_str()
254            .unwrap_or_default()
255            .contains("alpha"));
256        assert!(!filtered_payload["output"]
257            .as_str()
258            .unwrap_or_default()
259            .contains("beta"));
260
261        let second_out = output_tool
262            .invoke(
263                json!({ "bash_id": shell_id, "cursor": next_cursor }),
264                ToolCtx::none("t"),
265            )
266            .await
267            .unwrap();
268        let ToolOutcome::Completed(second) = second_out else {
269            panic!("expected Completed")
270        };
271        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
272        assert_eq!(second_payload["output"], "");
273    }
274
275    #[tokio::test]
276    async fn bash_output_tolerates_invalid_utf8_streams() {
277        let shell_id =
278            spawn_background_shell_id_for_command(invalid_utf8_background_command()).await;
279        wait_until_completed(&shell_id).await;
280
281        let output_tool = BashOutputTool::new();
282        let out = output_tool
283            .invoke(json!({ "bash_id": shell_id }), ToolCtx::none("t"))
284            .await
285            .unwrap();
286        let ToolOutcome::Completed(result) = out else {
287            panic!("expected Completed")
288        };
289        let payload: Value = serde_json::from_str(&result.result).unwrap();
290        let output = payload["output"].as_str().unwrap_or_default();
291        assert!(!output.is_empty());
292    }
293}