Skip to main content

crabtalk_runtime/os/
tool.rs

1//! Tool schemas and input types for OS tools.
2
3use crate::{Env, host::Host};
4use schemars::JsonSchema;
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use wcore::{
8    agent::{AsTool, ToolDescription},
9    model::Tool,
10};
11
12#[derive(Deserialize, JsonSchema)]
13pub struct Bash {
14    /// Shell command to run (e.g. `"ls -la"`, `"cat foo.txt | grep bar"`).
15    pub command: String,
16    /// Environment variables to set for the process.
17    #[serde(default)]
18    pub env: BTreeMap<String, String>,
19}
20
21impl ToolDescription for Bash {
22    const DESCRIPTION: &'static str = "Run a shell command.";
23}
24
25pub fn tools() -> Vec<Tool> {
26    vec![Bash::as_tool()]
27}
28
29impl<H: Host> Env<H> {
30    /// Dispatch a `bash` tool call — run a command directly.
31    ///
32    /// A non-zero exit code is *not* an `Err` — the shell ran successfully
33    /// and the JSON payload describes the outcome. Only dispatcher-level
34    /// failures (spawn error, wait error, timeout) become `Err`.
35    pub async fn dispatch_bash(
36        &self,
37        args: &str,
38        conversation_id: Option<u64>,
39    ) -> Result<String, String> {
40        let input: Bash =
41            serde_json::from_str(args).map_err(|e| format!("invalid arguments: {e}"))?;
42        let conversation_cwd = if let Some(id) = conversation_id {
43            self.host.conversation_cwd(id)
44        } else {
45            None
46        };
47        let cwd = conversation_cwd.as_deref().unwrap_or(&self.cwd);
48
49        let mut cmd = tokio::process::Command::new("bash");
50        cmd.args(["-c", &input.command])
51            .envs(&input.env)
52            .current_dir(cwd)
53            .stdout(std::process::Stdio::piped())
54            .stderr(std::process::Stdio::piped());
55
56        let child = cmd.spawn().map_err(|e| {
57            serde_json::json!({
58                "stdout": "",
59                "stderr": format!("bash failed: {e}"),
60                "exit_code": -1
61            })
62            .to_string()
63        })?;
64
65        match tokio::time::timeout(std::time::Duration::from_secs(30), child.wait_with_output())
66            .await
67        {
68            Ok(Ok(output)) => {
69                let stdout = String::from_utf8_lossy(&output.stdout);
70                let stderr = String::from_utf8_lossy(&output.stderr);
71                let exit_code = output.status.code().unwrap_or(-1);
72                Ok(serde_json::json!({
73                    "stdout": stdout.as_ref(),
74                    "stderr": stderr.as_ref(),
75                    "exit_code": exit_code
76                })
77                .to_string())
78            }
79            Ok(Err(e)) => Err(serde_json::json!({
80                "stdout": "",
81                "stderr": format!("bash failed: {e}"),
82                "exit_code": -1
83            })
84            .to_string()),
85            Err(_) => Err(serde_json::json!({
86                "stdout": "",
87                "stderr": "bash timed out after 30 seconds",
88                "exit_code": -1
89            })
90            .to_string()),
91        }
92    }
93}