consciousness/src/agent/tools/bash.rs
ProofOfConcept ed150df628 tools: each module exports only tool() or tools(), nothing else
Every tool module now has a clean interface:
- read, write, edit, grep, glob, bash, vision: pub fn tool() -> Tool
- web: pub fn tools() -> [Tool; 2]
- memory: pub fn memory_tools() -> Vec<Tool>
- channels: pub fn tools() -> Vec<Tool>
- control: pub fn tools() -> Vec<Tool>

Definition and handler functions are private to each module.
mod.rs::tools() just chains the module exports.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-04-04 18:19:21 -04:00

153 lines
4.8 KiB
Rust

// tools/bash.rs — Execute shell commands
//
// Runs commands through bash -c with a configurable timeout.
// Uses tokio's async process spawning so timeouts actually work.
//
// Processes are tracked in a shared ProcessTracker so the TUI can
// display running commands and the user can kill them (Ctrl+K).
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::json;
use std::process::Stdio;
use tokio::io::AsyncReadExt;
use super::{ToolDef, default_timeout};
/// RAII guard that SIGTERMs the process group on drop.
/// Ensures child processes are cleaned up when a task is aborted.
struct KillOnDrop(u32); // pid
impl Drop for KillOnDrop {
fn drop(&mut self) {
if self.0 != 0 {
unsafe { libc::kill(-(self.0 as i32), libc::SIGTERM); }
}
}
}
#[derive(Deserialize)]
struct Args {
command: String,
#[serde(default = "default_timeout")]
timeout_secs: u64,
}
pub fn tool() -> super::Tool {
super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { run_bash(&v).await }) }
}
fn definition() -> ToolDef {
ToolDef::new(
"bash",
"Execute a bash command and return its output. \
Use for git operations, building, running tests, and other terminal tasks.",
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
},
"timeout_secs": {
"type": "integer",
"description": "Timeout in seconds (default 120)"
}
},
"required": ["command"]
}),
)
}
async fn run_bash(args: &serde_json::Value) -> Result<String> {
let a: Args = serde_json::from_value(args.clone())
.context("invalid bash arguments")?;
let command = &a.command;
let timeout_secs = a.timeout_secs;
let mut child = tokio::process::Command::new("bash")
.arg("-c")
.arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// Create a process group so we can kill the whole tree
.process_group(0)
.spawn()
.with_context(|| format!("Failed to spawn: {}", command))?;
let pid = child.id().unwrap_or(0);
let kill_guard = KillOnDrop(pid);
// Take ownership of stdout/stderr handles before waiting,
// so we can still kill the child on timeout.
let mut stdout_handle = child.stdout.take().unwrap();
let mut stderr_handle = child.stderr.take().unwrap();
let timeout = std::time::Duration::from_secs(timeout_secs);
let work = async {
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let (_, _, status) = tokio::try_join!(
async { stdout_handle.read_to_end(&mut stdout_buf).await.map_err(anyhow::Error::from) },
async { stderr_handle.read_to_end(&mut stderr_buf).await.map_err(anyhow::Error::from) },
async { child.wait().await.map_err(anyhow::Error::from) },
)?;
Ok::<_, anyhow::Error>((stdout_buf, stderr_buf, status))
};
let result = match tokio::time::timeout(timeout, work).await {
Ok(Ok((stdout_buf, stderr_buf, status))) => {
let stdout = String::from_utf8_lossy(&stdout_buf);
let stderr = String::from_utf8_lossy(&stderr_buf);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("STDERR:\n");
result.push_str(&stderr);
}
// Detect if killed by signal (SIGTERM = 15)
if let Some(signal) = status.code() {
if signal == -1 || !status.success() {
result.push_str(&format!("\nExit code: {}", signal));
}
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
if sig == libc::SIGTERM {
result.push_str("\n(killed by user)");
}
}
}
if result.is_empty() {
result = "(no output)".to_string();
}
Ok(super::truncate_output(result, 30000))
}
Ok(Err(e)) => {
Err(anyhow::anyhow!("Command failed: {}", e))
}
Err(_) => {
// Timeout — KillOnDrop will SIGTERM the process group
Err(anyhow::anyhow!("Command timed out after {}s: {}", timeout_secs, command))
}
};
// Process completed normally — defuse the kill guard
std::mem::forget(kill_guard);
result
}