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        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::tools::bash::BashTool;
116    use serde_json::Value;
117    use tokio::time::{sleep, Duration};
118
119    #[cfg(target_os = "windows")]
120    fn background_command() -> &'static str {
121        "echo alpha && echo beta"
122    }
123
124    #[cfg(not(target_os = "windows"))]
125    fn background_command() -> &'static str {
126        "printf 'alpha\\n'; printf 'beta\\n'"
127    }
128
129    #[cfg(target_os = "windows")]
130    fn invalid_utf8_background_command() -> String {
131        let shell = bamboo_infrastructure::preferred_bash_shell();
132        if shell.arg == "-lc" {
133            "printf '\\377\\n'".to_string()
134        } else {
135            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardOutput().Write($bytes,0,$bytes.Length)\"".to_string()
136        }
137    }
138
139    #[cfg(not(target_os = "windows"))]
140    fn invalid_utf8_background_command() -> String {
141        "printf '\\377\\n'".to_string()
142    }
143
144    async fn spawn_background_shell_id() -> String {
145        let bash = BashTool::new();
146        let result = bash
147            .execute(json!({
148                "command": background_command(),
149                "run_in_background": true
150            }))
151            .await
152            .unwrap();
153
154        let payload: Value = serde_json::from_str(&result.result).unwrap();
155        payload["bash_id"].as_str().unwrap().to_string()
156    }
157
158    async fn spawn_background_shell_id_for_command(command: String) -> String {
159        let bash = BashTool::new();
160        let result = bash
161            .execute(json!({
162                "command": command,
163                "run_in_background": true
164            }))
165            .await
166            .unwrap();
167
168        let payload: Value = serde_json::from_str(&result.result).unwrap();
169        payload["bash_id"].as_str().unwrap().to_string()
170    }
171
172    async fn wait_until_completed(shell_id: &str) {
173        let shell = super::bash_runtime::get_shell(shell_id).unwrap();
174        for _ in 0..100 {
175            if shell.status() == "completed" {
176                return;
177            }
178            sleep(Duration::from_millis(10)).await;
179        }
180        panic!("background shell did not complete in time");
181    }
182
183    #[tokio::test]
184    async fn bash_output_reads_incrementally() {
185        let shell_id = spawn_background_shell_id().await;
186        wait_until_completed(&shell_id).await;
187
188        let output_tool = BashOutputTool::new();
189        let first = output_tool
190            .execute(json!({ "bash_id": shell_id }))
191            .await
192            .unwrap();
193        let first_payload: Value = serde_json::from_str(&first.result).unwrap();
194        let first_output = first_payload["output"].as_str().unwrap_or_default();
195        let next_cursor = first_payload["next_cursor"].as_u64().unwrap_or(0);
196        assert!(first_output.contains("alpha"));
197        assert!(first_output.contains("beta"));
198
199        let second = output_tool
200            .execute(json!({ "bash_id": shell_id, "cursor": next_cursor }))
201            .await
202            .unwrap();
203        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
204        assert_eq!(second_payload["output"], "");
205    }
206
207    #[tokio::test]
208    async fn bash_output_filter_consumes_unmatched_lines() {
209        let shell_id = spawn_background_shell_id().await;
210        wait_until_completed(&shell_id).await;
211
212        let output_tool = BashOutputTool::new();
213        let filtered = output_tool
214            .execute(json!({
215                "bash_id": shell_id,
216                "filter": "alpha"
217            }))
218            .await
219            .unwrap();
220        let filtered_payload: Value = serde_json::from_str(&filtered.result).unwrap();
221        let next_cursor = filtered_payload["next_cursor"].as_u64().unwrap_or(0);
222        assert!(filtered_payload["output"]
223            .as_str()
224            .unwrap_or_default()
225            .contains("alpha"));
226        assert!(!filtered_payload["output"]
227            .as_str()
228            .unwrap_or_default()
229            .contains("beta"));
230
231        let second = output_tool
232            .execute(json!({ "bash_id": shell_id, "cursor": next_cursor }))
233            .await
234            .unwrap();
235        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
236        assert_eq!(second_payload["output"], "");
237    }
238
239    #[tokio::test]
240    async fn bash_output_tolerates_invalid_utf8_streams() {
241        let shell_id =
242            spawn_background_shell_id_for_command(invalid_utf8_background_command()).await;
243        wait_until_completed(&shell_id).await;
244
245        let output_tool = BashOutputTool::new();
246        let result = output_tool
247            .execute(json!({ "bash_id": shell_id }))
248            .await
249            .unwrap();
250        let payload: Value = serde_json::from_str(&result.result).unwrap();
251        let output = payload["output"].as_str().unwrap_or_default();
252        assert!(!output.is_empty());
253    }
254}