Skip to main content

recursive/tools/
shell.rs

1//! `run_shell`: execute a command in the workspace.
2//!
3//! Uses `/bin/sh -c` so the model can write idiomatic one-liners (pipes,
4//! redirects, etc.). Stdout and stderr are captured and returned together.
5
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use std::path::PathBuf;
9use std::process::Stdio;
10use std::time::Duration;
11use tokio::io::AsyncReadExt;
12use tokio::process::Command;
13
14use super::Tool;
15use crate::error::{Error, Result};
16use crate::llm::ToolSpec;
17
18#[derive(Debug, Clone)]
19pub struct RunShell {
20    pub root: PathBuf,
21    pub timeout: Duration,
22    pub max_output_bytes: usize,
23}
24
25impl RunShell {
26    pub fn new(root: impl Into<PathBuf>) -> Self {
27        Self {
28            root: root.into(),
29            timeout: Duration::from_secs(300),
30            max_output_bytes: 128 * 1024,
31        }
32    }
33
34    pub fn with_timeout(mut self, t: Duration) -> Self {
35        self.timeout = t;
36        self
37    }
38}
39
40#[async_trait]
41impl Tool for RunShell {
42    fn spec(&self) -> ToolSpec {
43        ToolSpec {
44            name: "run_shell".into(),
45            description:
46                "Run a shell command (sh -c) from the workspace root. Returns combined stdout/stderr and exit status."
47                    .into(),
48            parameters: json!({
49                "type": "object",
50                "properties": {
51                    "command": {"type": "string", "description": "Command line to execute via sh -c"}
52                },
53                "required": ["command"]
54            }),
55        }
56    }
57
58    async fn execute(&self, args: Value) -> Result<String> {
59        let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
60            name: "run_shell".into(),
61            message: "missing `command`".into(),
62        })?;
63
64        let mut cmd = Command::new("/bin/sh");
65        cmd.arg("-c").arg(command);
66        cmd.current_dir(&self.root);
67        cmd.stdout(Stdio::piped());
68        cmd.stderr(Stdio::piped());
69
70        let mut child = cmd.spawn().map_err(|e| Error::Tool {
71            name: "run_shell".into(),
72            message: format!("spawn failed: {e}"),
73        })?;
74
75        let mut stdout = child.stdout.take().expect("stdout piped");
76        let mut stderr = child.stderr.take().expect("stderr piped");
77
78        let max = self.max_output_bytes;
79        let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
80        let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
81
82        let wait = child.wait();
83        let status = match tokio::time::timeout(self.timeout, wait).await {
84            Ok(s) => s.map_err(|e| Error::Tool {
85                name: "run_shell".into(),
86                message: format!("wait failed: {e}"),
87            })?,
88            Err(_) => {
89                return Err(Error::Tool {
90                    name: "run_shell".into(),
91                    message: format!("command timed out after {:?}", self.timeout),
92                });
93            }
94        };
95
96        let out = stdout_task.await.unwrap_or_default();
97        let err = stderr_task.await.unwrap_or_default();
98        let code = status
99            .code()
100            .map(|c| c.to_string())
101            .unwrap_or_else(|| "signal".into());
102
103        Ok(format!(
104            "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
105        ))
106    }
107}
108
109async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
110    let mut buf = Vec::with_capacity(8 * 1024);
111    let mut tmp = [0u8; 8 * 1024];
112    loop {
113        match reader.read(&mut tmp).await {
114            Ok(0) => break,
115            Ok(n) => {
116                if buf.len() + n > max {
117                    let take = max.saturating_sub(buf.len());
118                    buf.extend_from_slice(&tmp[..take]);
119                    buf.extend_from_slice(b"\n... [output truncated]");
120                    let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
121                    break;
122                }
123                buf.extend_from_slice(&tmp[..n]);
124            }
125            Err(_) => break,
126        }
127    }
128    String::from_utf8_lossy(&buf).into_owned()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use tempfile::TempDir;
135
136    #[tokio::test]
137    async fn runs_echo_in_workspace() {
138        let tmp = TempDir::new().unwrap();
139        let out = RunShell::new(tmp.path())
140            .execute(json!({"command": "echo hello && pwd"}))
141            .await
142            .unwrap();
143        assert!(out.contains("exit: 0"));
144        assert!(out.contains("hello"));
145    }
146
147    #[tokio::test]
148    async fn captures_nonzero_status() {
149        let tmp = TempDir::new().unwrap();
150        let out = RunShell::new(tmp.path())
151            .execute(json!({"command": "exit 7"}))
152            .await
153            .unwrap();
154        assert!(out.contains("exit: 7"));
155    }
156
157    #[tokio::test]
158    async fn enforces_timeout() {
159        let tmp = TempDir::new().unwrap();
160        let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
161        let err = tool
162            .execute(json!({"command": "sleep 5"}))
163            .await
164            .unwrap_err();
165        assert!(matches!(err, Error::Tool { .. }));
166    }
167}