use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;
use theway_core::{AgentTool, AgentToolError, AgentToolResult, AgentToolUpdate};
use theway_llm_provider::{Tool, UserContentBlock};
use tokio_util::sync::CancellationToken;
use super::exec::run_with_kill_on_timeout_or_cancel;
use super::truncate::{DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncate_tail};
const DEFAULT_TIMEOUT_SECS: u64 = 60;
fn resolve_timeout(params: &Value) -> u64 {
params
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_SECS)
}
pub struct BashTool;
#[async_trait]
impl AgentTool for BashTool {
fn definition(&self) -> &Tool {
&DEFINITION
}
fn label(&self) -> &str {
"bash"
}
async fn execute(
&self,
_id: &str,
params: Value,
cancel: CancellationToken,
_on_update: Option<AgentToolUpdate>,
) -> Result<AgentToolResult, AgentToolError> {
let command = params
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| AgentToolError::from("missing `command`"))?;
let timeout_secs = Some(resolve_timeout(¶ms));
let run_in_background = params
.get("run_in_background")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let cwd = params.get("cwd").and_then(|v| v.as_str()).map(String::from);
if run_in_background {
let bg = crate::tools::exec_shell::run_in_background_with_cwd(
command,
cwd.as_deref().map(std::path::Path::new),
)
.await?;
let text = format!("background shell started: {} (pid {})", bg.id, bg.pid);
return Ok(AgentToolResult {
content: vec![UserContentBlock::text(text)],
details: json!({ "command": command, "shellId": bg.id, "pid": bg.pid }),
terminate: None,
});
}
let outcome = run_with_kill_on_timeout_or_cancel(
command,
timeout_secs.map(Duration::from_secs),
cwd.as_deref().map(std::path::Path::new),
None,
&cancel,
)
.await?;
let exit = outcome.rendered_exit();
let mut stderr_full = outcome.stderr;
if let Some(suffix) = &outcome.stderr_suffix {
if !stderr_full.is_empty() && !stderr_full.ends_with('\n') {
stderr_full.push('\n');
}
stderr_full.push_str(suffix);
}
let mut full_text = format!("$ {command}\n");
if !outcome.stdout.is_empty() {
full_text.push_str(&outcome.stdout);
if !outcome.stdout.ends_with('\n') {
full_text.push('\n');
}
}
if !stderr_full.is_empty() {
full_text.push_str("[stderr]\n");
full_text.push_str(&stderr_full);
if !stderr_full.ends_with('\n') {
full_text.push('\n');
}
}
full_text.push_str(&format!("[exit {exit}]"));
let (stdout_trim, st) =
truncate_tail(&outcome.stdout, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
let (stderr_trim, stderr_st) =
truncate_tail(&stderr_full, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
let mut text = format!("$ {command}\n");
if let Some(note) = st.note() {
text.push_str(¬e);
text.push('\n');
}
if !stdout_trim.is_empty() {
text.push_str(&stdout_trim);
if !stdout_trim.ends_with('\n') {
text.push('\n');
}
}
if !stderr_trim.is_empty() {
text.push_str("[stderr]\n");
text.push_str(&stderr_trim);
if !stderr_trim.ends_with('\n') {
text.push('\n');
}
}
text.push_str(&format!("[exit {exit}]"));
let is_error = exit != 0;
let truncated = st.truncated_lines > 0 || stderr_st.truncated_lines > 0;
Ok(AgentToolResult {
content: vec![UserContentBlock::text(text)],
details: json!({
"command": command,
"exitCode": exit,
"isError": is_error,
"full_text": full_text,
"truncated": truncated,
}),
terminate: None,
})
}
}
use once_cell::sync::Lazy;
static DEFINITION: Lazy<Tool> = Lazy::new(|| Tool {
name: "bash".into(),
description: format!(
"Run a shell command via `sh -c`. With `run_in_background: true` the command runs in a background shell and the tool returns its shell_id immediately — manage it with get_output / kill_shell / write_to_process. Returns stdout+stderr (tail-truncated to {DEFAULT_MAX_LINES} lines / {} KiB) and exit code. Optional `timeout` in seconds. Timeouts and cancellations kill the child process; stdout and stderr are drained concurrently so high-output commands do not deadlock the tool.",
DEFAULT_MAX_BYTES / 1024
),
parameters: json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command to execute" },
"run_in_background": { "type": "boolean", "description": "If true, start a background shell and return its shell_id immediately instead of waiting" },
"cwd": { "type": "string", "description": "Working directory to run the command in (absolute path). Optional; defaults to the session cwd" },
"timeout": { "type": "integer", "description": "Timeout in seconds (optional). On timeout the child is killed and any output captured so far is returned." },
},
"required": ["command"],
}),
});
#[cfg(test)]
tests_bridge_macro::tests_bridge!("tools/bash");
#[cfg(test)]
mod bash_extra {
tests_bridge_macro::tests_bridge!("tools/bash/extra");
}