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()).ok_or_else(|| {
77            ToolError::Execution(format!("Background shell '{}' not found", parsed.bash_id))
78        })?;
79
80        let regex = parsed
81            .filter
82            .as_ref()
83            .map(|value| {
84                Regex::new(value).map_err(|e| {
85                    ToolError::InvalidArguments(format!("Invalid filter regex: {}", e))
86                })
87            })
88            .transpose()?;
89
90        let cursor = parsed.cursor.unwrap_or(0);
91        let (lines, next_cursor, dropped_lines) =
92            shell.read_output_since(cursor, regex.as_ref()).await;
93        let status = shell.status();
94        let exit_code = shell.exit_code().await;
95
96        Ok(ToolOutcome::Completed(ToolResult {
97            success: true,
98            result: json!({
99                "bash_id": parsed.bash_id,
100                "status": status,
101                "exit_code": exit_code,
102                "next_cursor": next_cursor,
103                "dropped_lines": dropped_lines,
104                "output": lines.join("\n"),
105            })
106            .to_string(),
107            display_preference: Some("Collapsible".to_string()),
108            images: Vec::new(),
109        }))
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::tools::bash::BashTool;
117    use serde_json::Value;
118    use tokio::time::{sleep, Duration};
119
120    #[cfg(target_os = "windows")]
121    fn background_command() -> &'static str {
122        "echo alpha && echo beta"
123    }
124
125    #[cfg(not(target_os = "windows"))]
126    fn background_command() -> &'static str {
127        "printf 'alpha\\n'; printf 'beta\\n'"
128    }
129
130    #[cfg(target_os = "windows")]
131    fn invalid_utf8_background_command() -> String {
132        let shell = bamboo_infrastructure::process::preferred_bash_shell();
133        if shell.arg == "-lc" {
134            "printf '\\377\\n'".to_string()
135        } else {
136            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardOutput().Write($bytes,0,$bytes.Length)\"".to_string()
137        }
138    }
139
140    #[cfg(not(target_os = "windows"))]
141    fn invalid_utf8_background_command() -> String {
142        "printf '\\377\\n'".to_string()
143    }
144
145    async fn spawn_background_shell_id() -> String {
146        let bash = BashTool::new();
147        let out = bash
148            .invoke(
149                json!({
150                    "command": background_command(),
151                    "run_in_background": true
152                }),
153                ToolCtx::none("t"),
154            )
155            .await
156            .unwrap();
157        let ToolOutcome::Completed(result) = out else {
158            panic!("expected Completed")
159        };
160
161        let payload: Value = serde_json::from_str(&result.result).unwrap();
162        payload["bash_id"].as_str().unwrap().to_string()
163    }
164
165    async fn spawn_background_shell_id_for_command(command: String) -> String {
166        let bash = BashTool::new();
167        let out = bash
168            .invoke(
169                json!({
170                    "command": command,
171                    "run_in_background": true
172                }),
173                ToolCtx::none("t"),
174            )
175            .await
176            .unwrap();
177        let ToolOutcome::Completed(result) = out else {
178            panic!("expected Completed")
179        };
180
181        let payload: Value = serde_json::from_str(&result.result).unwrap();
182        payload["bash_id"].as_str().unwrap().to_string()
183    }
184
185    async fn wait_until_completed(shell_id: &str) {
186        let shell = super::bash_runtime::get_shell(shell_id).unwrap();
187        for _ in 0..100 {
188            if shell.status() == "completed" {
189                return;
190            }
191            sleep(Duration::from_millis(10)).await;
192        }
193        panic!("background shell did not complete in time");
194    }
195
196    #[tokio::test]
197    async fn bash_output_reads_incrementally() {
198        let shell_id = spawn_background_shell_id().await;
199        wait_until_completed(&shell_id).await;
200
201        let output_tool = BashOutputTool::new();
202        let first_out = output_tool
203            .invoke(json!({ "bash_id": shell_id }), ToolCtx::none("t"))
204            .await
205            .unwrap();
206        let ToolOutcome::Completed(first) = first_out else {
207            panic!("expected Completed")
208        };
209        let first_payload: Value = serde_json::from_str(&first.result).unwrap();
210        let first_output = first_payload["output"].as_str().unwrap_or_default();
211        let next_cursor = first_payload["next_cursor"].as_u64().unwrap_or(0);
212        assert!(first_output.contains("alpha"));
213        assert!(first_output.contains("beta"));
214
215        let second_out = output_tool
216            .invoke(
217                json!({ "bash_id": shell_id, "cursor": next_cursor }),
218                ToolCtx::none("t"),
219            )
220            .await
221            .unwrap();
222        let ToolOutcome::Completed(second) = second_out else {
223            panic!("expected Completed")
224        };
225        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
226        assert_eq!(second_payload["output"], "");
227    }
228
229    #[tokio::test]
230    async fn bash_output_filter_consumes_unmatched_lines() {
231        let shell_id = spawn_background_shell_id().await;
232        wait_until_completed(&shell_id).await;
233
234        let output_tool = BashOutputTool::new();
235        let filtered_out = output_tool
236            .invoke(
237                json!({
238                    "bash_id": shell_id,
239                    "filter": "alpha"
240                }),
241                ToolCtx::none("t"),
242            )
243            .await
244            .unwrap();
245        let ToolOutcome::Completed(filtered) = filtered_out else {
246            panic!("expected Completed")
247        };
248        let filtered_payload: Value = serde_json::from_str(&filtered.result).unwrap();
249        let next_cursor = filtered_payload["next_cursor"].as_u64().unwrap_or(0);
250        assert!(filtered_payload["output"]
251            .as_str()
252            .unwrap_or_default()
253            .contains("alpha"));
254        assert!(!filtered_payload["output"]
255            .as_str()
256            .unwrap_or_default()
257            .contains("beta"));
258
259        let second_out = output_tool
260            .invoke(
261                json!({ "bash_id": shell_id, "cursor": next_cursor }),
262                ToolCtx::none("t"),
263            )
264            .await
265            .unwrap();
266        let ToolOutcome::Completed(second) = second_out else {
267            panic!("expected Completed")
268        };
269        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
270        assert_eq!(second_payload["output"], "");
271    }
272
273    #[tokio::test]
274    async fn bash_output_tolerates_invalid_utf8_streams() {
275        let shell_id =
276            spawn_background_shell_id_for_command(invalid_utf8_background_command()).await;
277        wait_until_completed(&shell_id).await;
278
279        let output_tool = BashOutputTool::new();
280        let out = output_tool
281            .invoke(json!({ "bash_id": shell_id }), ToolCtx::none("t"))
282            .await
283            .unwrap();
284        let ToolOutcome::Completed(result) = out else {
285            panic!("expected Completed")
286        };
287        let payload: Value = serde_json::from_str(&result.result).unwrap();
288        let output = payload["output"].as_str().unwrap_or_default();
289        assert!(!output.is_empty());
290    }
291}