bamboo_tools/tools/
bash_output.rs1use 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}