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 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}