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