use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use adk_core::{Result, Tool, ToolContext};
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::io::{AsyncRead, AsyncReadExt};
use crate::error::DevToolError;
use crate::tools::read::require_str;
use crate::workspace::Workspace;
pub struct BashTool {
workspace: Workspace,
}
impl BashTool {
pub fn new(workspace: Workspace) -> Self {
Self { workspace }
}
}
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Run a shell command in the workspace root and return stdout, stderr, and the \
exit code. Streams output incrementally for live UI display. Has a timeout."
}
fn parameters_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The shell command to run." },
"timeout_secs": { "type": "integer", "description": "Optional timeout in seconds (default: workspace setting)." }
},
"required": ["command"]
}))
}
async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
if !self.workspace.bash_allowed() {
return Err(DevToolError::BashDisabled.into());
}
let command = require_str(&args, "command")?;
let timeout = args
.get("timeout_secs")
.and_then(Value::as_u64)
.map(Duration::from_secs)
.unwrap_or_else(|| self.workspace.bash_timeout_value());
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c")
.arg(&command)
.current_dir(self.workspace.root())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if !self.workspace.inherits_env() {
cmd.env_clear();
for (key, value) in self.workspace.bash_env() {
cmd.env(key, value);
}
}
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd.spawn().map_err(DevToolError::from)?;
let mut group = ProcessGroupGuard { pid: child.id() };
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let cap = self.workspace.max_output();
let result = tokio::time::timeout(timeout, async {
let (stdout, stderr) = tokio::join!(
capture(stdout_pipe, cap, ctx.as_ref(), "stdout"),
capture(stderr_pipe, cap, ctx.as_ref(), "stderr"),
);
let status = child.wait().await?;
group.disarm();
Ok::<_, std::io::Error>((status, stdout, stderr))
})
.await;
match result {
Ok(Ok((status, (stdout, out_exceeded), (stderr, err_exceeded)))) => {
let (stdout, out_trunc) = bound_output(&stdout, cap, out_exceeded);
let (stderr, err_trunc) = bound_output(&stderr, cap, err_exceeded);
Ok(json!({
"command": command,
"exit_code": status.code(),
"stdout": stdout,
"stderr": stderr,
"truncated": out_trunc || err_trunc,
}))
}
Ok(Err(e)) => Err(DevToolError::from(e).into()),
Err(_) => {
group.kill();
let _ = child.start_kill();
ctx.emit_progress("stderr", &format!("\n[timeout after {}s]\n", timeout.as_secs()))
.await;
Err(DevToolError::Timeout(timeout).into())
}
}
}
}
struct ProcessGroupGuard {
pid: Option<u32>,
}
impl ProcessGroupGuard {
fn kill(&self) {
#[cfg(unix)]
if let Some(pid) = self.pid {
unsafe {
libc::killpg(pid as libc::pid_t, libc::SIGKILL);
}
}
#[cfg(not(unix))]
let _ = self.pid;
}
fn disarm(&mut self) {
self.pid = None;
}
}
impl Drop for ProcessGroupGuard {
fn drop(&mut self) {
self.kill();
}
}
async fn capture(
pipe: Option<impl AsyncRead + Unpin>,
cap: usize,
ctx: &dyn ToolContext,
stream: &str,
) -> (Vec<u8>, bool) {
let mut kept = Vec::new();
let mut exceeded = false;
let Some(mut pipe) = pipe else {
return (kept, exceeded);
};
let mut chunk = [0u8; 8192];
let mut emitted = 0;
loop {
let read = match pipe.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(read) => read,
};
let room = cap - kept.len();
if read > room && !exceeded {
exceeded = true;
tracing::debug!(
output.stream = stream,
output.cap = cap,
"discarding output past the cap"
);
}
kept.extend_from_slice(&chunk[..read.min(room)]);
if let Some(newline) = kept[emitted..].iter().rposition(|&byte| byte == b'\n') {
let end = emitted + newline + 1;
ctx.emit_progress(stream, &String::from_utf8_lossy(&kept[emitted..end])).await;
emitted = end;
}
}
if emitted < kept.len() {
ctx.emit_progress(stream, &String::from_utf8_lossy(&kept[emitted..])).await;
}
(kept, exceeded)
}
fn bound_output(bytes: &[u8], cap: usize, exceeded: bool) -> (String, bool) {
let mut text = String::from_utf8_lossy(bytes).into_owned();
let cut = text.len() > cap;
if cut {
text.truncate(text.floor_char_boundary(cap));
}
let truncated = exceeded || cut;
if truncated {
text.push_str("\n…[truncated]");
}
(text, truncated)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cap_inside_a_multi_byte_character_does_not_panic() {
let (text, truncated) = bound_output("€€€".as_bytes(), 4, false);
assert_eq!((text.as_str(), truncated), ("€\n…[truncated]", true));
}
#[test]
fn a_character_split_by_the_capture_cap_is_dropped() {
let (text, truncated) = bound_output(&"aé".as_bytes()[..2], 2, true);
assert_eq!((text.as_str(), truncated), ("a\n…[truncated]", true));
}
#[test]
fn output_within_the_cap_is_unchanged() {
let (text, truncated) = bound_output("héllo\n".as_bytes(), 1024, false);
assert_eq!((text.as_str(), truncated), ("héllo\n", false));
}
}