Skip to main content

bamboo_tools/tools/
bash_output.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, 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 mutability(&self) -> crate::ToolMutability {
43        crate::ToolMutability::ReadOnly
44    }
45
46    fn concurrency_safe(&self) -> bool {
47        true
48    }
49
50    fn parameters_schema(&self) -> serde_json::Value {
51        json!({
52            "type": "object",
53            "properties": {
54                "bash_id": {
55                    "type": "string",
56                    "description": "The ID of the background shell to retrieve output from"
57                },
58                "filter": {
59                    "type": "string",
60                    "description": "Optional regular expression to filter output lines"
61                },
62                "cursor": {
63                    "type": "number",
64                    "description": "Read output starting from this cursor (0 for beginning)"
65                }
66            },
67            "required": ["bash_id"],
68            "additionalProperties": false
69        })
70    }
71
72    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, 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(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 result = bash
148            .execute(json!({
149                "command": background_command(),
150                "run_in_background": true
151            }))
152            .await
153            .unwrap();
154
155        let payload: Value = serde_json::from_str(&result.result).unwrap();
156        payload["bash_id"].as_str().unwrap().to_string()
157    }
158
159    async fn spawn_background_shell_id_for_command(command: String) -> String {
160        let bash = BashTool::new();
161        let result = bash
162            .execute(json!({
163                "command": command,
164                "run_in_background": true
165            }))
166            .await
167            .unwrap();
168
169        let payload: Value = serde_json::from_str(&result.result).unwrap();
170        payload["bash_id"].as_str().unwrap().to_string()
171    }
172
173    async fn wait_until_completed(shell_id: &str) {
174        let shell = super::bash_runtime::get_shell(shell_id).unwrap();
175        for _ in 0..100 {
176            if shell.status() == "completed" {
177                return;
178            }
179            sleep(Duration::from_millis(10)).await;
180        }
181        panic!("background shell did not complete in time");
182    }
183
184    #[tokio::test]
185    async fn bash_output_reads_incrementally() {
186        let shell_id = spawn_background_shell_id().await;
187        wait_until_completed(&shell_id).await;
188
189        let output_tool = BashOutputTool::new();
190        let first = output_tool
191            .execute(json!({ "bash_id": shell_id }))
192            .await
193            .unwrap();
194        let first_payload: Value = serde_json::from_str(&first.result).unwrap();
195        let first_output = first_payload["output"].as_str().unwrap_or_default();
196        let next_cursor = first_payload["next_cursor"].as_u64().unwrap_or(0);
197        assert!(first_output.contains("alpha"));
198        assert!(first_output.contains("beta"));
199
200        let second = output_tool
201            .execute(json!({ "bash_id": shell_id, "cursor": next_cursor }))
202            .await
203            .unwrap();
204        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
205        assert_eq!(second_payload["output"], "");
206    }
207
208    #[tokio::test]
209    async fn bash_output_filter_consumes_unmatched_lines() {
210        let shell_id = spawn_background_shell_id().await;
211        wait_until_completed(&shell_id).await;
212
213        let output_tool = BashOutputTool::new();
214        let filtered = output_tool
215            .execute(json!({
216                "bash_id": shell_id,
217                "filter": "alpha"
218            }))
219            .await
220            .unwrap();
221        let filtered_payload: Value = serde_json::from_str(&filtered.result).unwrap();
222        let next_cursor = filtered_payload["next_cursor"].as_u64().unwrap_or(0);
223        assert!(filtered_payload["output"]
224            .as_str()
225            .unwrap_or_default()
226            .contains("alpha"));
227        assert!(!filtered_payload["output"]
228            .as_str()
229            .unwrap_or_default()
230            .contains("beta"));
231
232        let second = output_tool
233            .execute(json!({ "bash_id": shell_id, "cursor": next_cursor }))
234            .await
235            .unwrap();
236        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
237        assert_eq!(second_payload["output"], "");
238    }
239
240    #[tokio::test]
241    async fn bash_output_tolerates_invalid_utf8_streams() {
242        let shell_id =
243            spawn_background_shell_id_for_command(invalid_utf8_background_command()).await;
244        wait_until_completed(&shell_id).await;
245
246        let output_tool = BashOutputTool::new();
247        let result = output_tool
248            .execute(json!({ "bash_id": shell_id }))
249            .await
250            .unwrap();
251        let payload: Value = serde_json::from_str(&result.result).unwrap();
252        let output = payload["output"].as_str().unwrap_or_default();
253        assert!(!output.is_empty());
254    }
255}