use crate::core::{Tool, ToolArgs, ToolError, ToolResult};
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const MAX_OUTPUT_LENGTH: usize = 30_000;
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BashParams {
pub command: String,
pub timeout: Option<u64>,
pub workdir: Option<String>,
pub description: Option<String>,
}
pub struct BashTool {
name: String,
}
impl BashTool {
pub fn new() -> Self {
Self {
name: "bash".to_string(),
}
}
}
impl Default for BashTool {
fn default() -> Self {
Self::new()
}
}
impl Tool for BashTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Executes a given bash command in a persistent shell session with optional timeout"
}
fn signature(&self) -> &str {
"bash --command <command> [--timeout <ms>] [--workdir <dir>] [--description <desc>]"
}
fn validate_args(&self, args: &ToolArgs) -> Result<(), ToolError> {
if args.get_named_arg("command").is_none() && args.args.is_empty() {
return Err(ToolError::InvalidArgs {
message: "bash tool requires a 'command' argument".to_string(),
});
}
Ok(())
}
fn execute(
&mut self,
args: &ToolArgs,
state: &Arc<Mutex<crate::state::ToolState>>,
) -> Result<ToolResult> {
let params = parse_bash_args(args)?;
let cancel_signal = {
let state_guard = state.lock().map_err(|e| anyhow::anyhow!("Failed to lock tool state: {}", e))?;
state_guard.cancel_signal()
};
let workdir = params.workdir.map(PathBuf::from).unwrap_or_else(|| {
state
.lock()
.map(|s| s.working_directory.clone())
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
});
let timeout = params.timeout.unwrap_or(DEFAULT_TIMEOUT_MS);
let (shell, flag) = if cfg!(target_os = "windows") {
("cmd.exe", "/C")
} else {
("/bin/sh", "-c")
};
let start = Instant::now();
let mut child = Command::new(shell)
.arg(flag)
.arg(¶ms.command)
.current_dir(&workdir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to execute command: {}", e))?;
let mut output = String::new();
let mut truncated = false;
let mut cancelled = false;
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines() {
if cancel_signal.load(Ordering::Relaxed) {
cancelled = true;
let _ = child.kill();
break;
}
if start.elapsed() > Duration::from_millis(timeout) {
truncated = true;
let _ = child.kill();
break;
}
if let Ok(line) = line {
if output.len() + line.len() + 1 > MAX_OUTPUT_LENGTH {
truncated = true;
break;
}
output.push_str(&line);
output.push('\n');
}
}
}
if !truncated && !cancelled {
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
for line in reader.lines() {
if cancel_signal.load(Ordering::Relaxed) {
cancelled = true;
let _ = child.kill();
break;
}
if start.elapsed() > Duration::from_millis(timeout) {
truncated = true;
let _ = child.kill();
break;
}
if let Ok(line) = line {
if output.len() + line.len() + 1 > MAX_OUTPUT_LENGTH {
truncated = true;
break;
}
output.push_str(&line);
output.push('\n');
}
}
}
}
if cancelled {
let _ = child.wait();
return Ok(ToolResult::success_with_data(
"Command cancelled by user (ESC)".to_string(),
serde_json::json!({
"exit_code": -1,
"description": params.description,
}),
));
}
let exit_status = child
.wait()
.map_err(|e| anyhow::anyhow!("Failed to wait for command: {}", e))?;
let exit_code = exit_status.code().unwrap_or(-1);
let mut result_output = output.clone();
if truncated || start.elapsed() > Duration::from_millis(timeout) {
result_output.push_str("\n\n<bash_metadata>\n");
if result_output.len() >= MAX_OUTPUT_LENGTH {
result_output.push_str(&format!(
"bash tool truncated output as it exceeded {} char limit\n",
MAX_OUTPUT_LENGTH
));
}
if start.elapsed() > Duration::from_millis(timeout) {
result_output.push_str(&format!(
"bash tool terminated command after exceeding timeout {} ms\n",
timeout
));
}
result_output.push_str("</bash_metadata>");
}
Ok(ToolResult::success_with_data(
result_output,
serde_json::json!({
"exit_code": exit_code,
"description": params.description,
}),
))
}
fn get_parameters_schema(&self) -> serde_json::Value {
let schema = schemars::schema_for!(BashParams);
serde_json::to_value(schema).unwrap_or_default()
}
}
fn parse_bash_args(args: &ToolArgs) -> Result<BashParams> {
let command = args
.get_named_arg("command")
.cloned()
.or_else(|| args.args.first().cloned())
.ok_or_else(|| anyhow::anyhow!("command is required"))?;
let timeout = args
.get_named_arg("timeout")
.and_then(|s| s.parse::<u64>().ok());
let workdir = args
.get_named_arg("workdir")
.cloned()
.filter(|s| !s.is_empty());
let description = args.get_named_arg("description").cloned();
Ok(BashParams {
command,
timeout,
workdir,
description,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bash_tool_creation() {
let tool = BashTool::new();
assert_eq!(tool.name(), "bash");
}
#[test]
fn test_bash_tool_echo() {
let mut tool = BashTool::new();
let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
let args = ToolArgs::from_args(&["echo hello"]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("hello"));
}
#[test]
fn test_bash_tool_with_timeout() {
let mut tool = BashTool::new();
let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
let args = ToolArgs::with_named_args(
vec!["sleep 1".to_string()],
vec![("timeout".to_string(), "100".to_string())]
.into_iter()
.collect(),
);
let result = tool.execute(&args, &state).unwrap();
assert!(result.message.contains("timeout") || result.success);
}
#[test]
fn test_bash_tool_validation() {
let tool = BashTool::new();
let args = ToolArgs::from_args(&[]);
let result = tool.validate_args(&args);
assert!(result.is_err());
}
}