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