bamboo-tools 2026.4.28

Tool execution and integrations for the Bamboo agent framework
Documentation
use async_trait::async_trait;
use bamboo_agent_core::{Tool, ToolError, ToolResult};
use regex::Regex;
use serde::Deserialize;
use serde_json::json;

use super::bash_runtime;

#[derive(Debug, Deserialize)]
struct BashOutputArgs {
    bash_id: String,
    #[serde(default)]
    cursor: Option<usize>,
    #[serde(default)]
    filter: Option<String>,
}

pub struct BashOutputTool;

impl BashOutputTool {
    pub fn new() -> Self {
        Self
    }
}

impl Default for BashOutputTool {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl Tool for BashOutputTool {
    fn name(&self) -> &str {
        "BashOutput"
    }

    fn description(&self) -> &str {
        "Retrieve incremental output from a running or completed background Bash shell. Use the returned cursor to continue reading without replaying earlier lines."
    }

    fn mutability(&self) -> crate::ToolMutability {
        crate::ToolMutability::ReadOnly
    }

    fn concurrency_safe(&self) -> bool {
        true
    }

    fn parameters_schema(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": {
                "bash_id": {
                    "type": "string",
                    "description": "The ID of the background shell to retrieve output from"
                },
                "filter": {
                    "type": "string",
                    "description": "Optional regular expression to filter output lines"
                },
                "cursor": {
                    "type": "number",
                    "description": "Read output starting from this cursor (0 for beginning)"
                }
            },
            "required": ["bash_id"],
            "additionalProperties": false
        })
    }

    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
        let parsed: BashOutputArgs = serde_json::from_value(args)
            .map_err(|e| ToolError::InvalidArguments(format!("Invalid BashOutput args: {}", e)))?;

        let shell = bash_runtime::get_shell(parsed.bash_id.trim()).ok_or_else(|| {
            ToolError::Execution(format!("Background shell '{}' not found", parsed.bash_id))
        })?;

        let regex = parsed
            .filter
            .as_ref()
            .map(|value| {
                Regex::new(value).map_err(|e| {
                    ToolError::InvalidArguments(format!("Invalid filter regex: {}", e))
                })
            })
            .transpose()?;

        let cursor = parsed.cursor.unwrap_or(0);
        let (lines, next_cursor, dropped_lines) =
            shell.read_output_since(cursor, regex.as_ref()).await;
        let status = shell.status();
        let exit_code = shell.exit_code().await;

        Ok(ToolResult {
            success: true,
            result: json!({
                "bash_id": parsed.bash_id,
                "status": status,
                "exit_code": exit_code,
                "next_cursor": next_cursor,
                "dropped_lines": dropped_lines,
                "output": lines.join("\n"),
            })
            .to_string(),
            display_preference: Some("Collapsible".to_string()),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::bash::BashTool;
    use serde_json::Value;
    use tokio::time::{sleep, Duration};

    #[cfg(target_os = "windows")]
    fn background_command() -> &'static str {
        "echo alpha && echo beta"
    }

    #[cfg(not(target_os = "windows"))]
    fn background_command() -> &'static str {
        "printf 'alpha\\n'; printf 'beta\\n'"
    }

    #[cfg(target_os = "windows")]
    fn invalid_utf8_background_command() -> String {
        let shell = bamboo_infrastructure::preferred_bash_shell();
        if shell.arg == "-lc" {
            "printf '\\377\\n'".to_string()
        } else {
            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardOutput().Write($bytes,0,$bytes.Length)\"".to_string()
        }
    }

    #[cfg(not(target_os = "windows"))]
    fn invalid_utf8_background_command() -> String {
        "printf '\\377\\n'".to_string()
    }

    async fn spawn_background_shell_id() -> String {
        let bash = BashTool::new();
        let result = bash
            .execute(json!({
                "command": background_command(),
                "run_in_background": true
            }))
            .await
            .unwrap();

        let payload: Value = serde_json::from_str(&result.result).unwrap();
        payload["bash_id"].as_str().unwrap().to_string()
    }

    async fn spawn_background_shell_id_for_command(command: String) -> String {
        let bash = BashTool::new();
        let result = bash
            .execute(json!({
                "command": command,
                "run_in_background": true
            }))
            .await
            .unwrap();

        let payload: Value = serde_json::from_str(&result.result).unwrap();
        payload["bash_id"].as_str().unwrap().to_string()
    }

    async fn wait_until_completed(shell_id: &str) {
        let shell = super::bash_runtime::get_shell(shell_id).unwrap();
        for _ in 0..100 {
            if shell.status() == "completed" {
                return;
            }
            sleep(Duration::from_millis(10)).await;
        }
        panic!("background shell did not complete in time");
    }

    #[tokio::test]
    async fn bash_output_reads_incrementally() {
        let shell_id = spawn_background_shell_id().await;
        wait_until_completed(&shell_id).await;

        let output_tool = BashOutputTool::new();
        let first = output_tool
            .execute(json!({ "bash_id": shell_id }))
            .await
            .unwrap();
        let first_payload: Value = serde_json::from_str(&first.result).unwrap();
        let first_output = first_payload["output"].as_str().unwrap_or_default();
        let next_cursor = first_payload["next_cursor"].as_u64().unwrap_or(0);
        assert!(first_output.contains("alpha"));
        assert!(first_output.contains("beta"));

        let second = output_tool
            .execute(json!({ "bash_id": shell_id, "cursor": next_cursor }))
            .await
            .unwrap();
        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
        assert_eq!(second_payload["output"], "");
    }

    #[tokio::test]
    async fn bash_output_filter_consumes_unmatched_lines() {
        let shell_id = spawn_background_shell_id().await;
        wait_until_completed(&shell_id).await;

        let output_tool = BashOutputTool::new();
        let filtered = output_tool
            .execute(json!({
                "bash_id": shell_id,
                "filter": "alpha"
            }))
            .await
            .unwrap();
        let filtered_payload: Value = serde_json::from_str(&filtered.result).unwrap();
        let next_cursor = filtered_payload["next_cursor"].as_u64().unwrap_or(0);
        assert!(filtered_payload["output"]
            .as_str()
            .unwrap_or_default()
            .contains("alpha"));
        assert!(!filtered_payload["output"]
            .as_str()
            .unwrap_or_default()
            .contains("beta"));

        let second = output_tool
            .execute(json!({ "bash_id": shell_id, "cursor": next_cursor }))
            .await
            .unwrap();
        let second_payload: Value = serde_json::from_str(&second.result).unwrap();
        assert_eq!(second_payload["output"], "");
    }

    #[tokio::test]
    async fn bash_output_tolerates_invalid_utf8_streams() {
        let shell_id =
            spawn_background_shell_id_for_command(invalid_utf8_background_command()).await;
        wait_until_completed(&shell_id).await;

        let output_tool = BashOutputTool::new();
        let result = output_tool
            .execute(json!({ "bash_id": shell_id }))
            .await
            .unwrap();
        let payload: Value = serde_json::from_str(&result.result).unwrap();
        let output = payload["output"].as_str().unwrap_or_default();
        assert!(!output.is_empty());
    }
}