Skip to main content

cascade_agent/skills/
executor.rs

1//! Executor for skill executables (stdin/stdout JSON protocol).
2
3use std::path::Path;
4use std::time::Duration;
5
6use tokio::io::AsyncWriteExt;
7use tokio::process::Command;
8use tracing::{debug, error, warn};
9
10use crate::tools::ToolResult;
11
12/// Default timeout for skill executable invocations.
13pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
14
15/// Executes skill executables via the stdin/stdout JSON protocol.
16///
17/// Protocol:
18/// 1. The executable is spawned with its cwd set to the skill directory.
19/// 2. The tool arguments (`serde_json::Value`) are serialised as JSON and
20///    written to the process's stdin, followed by EOF.
21/// 3. The process's stdout is read in full and parsed as a JSON
22///    [`ToolResult`].
23/// 4. Stderr is captured and logged via `tracing`.
24pub struct SkillExecutor;
25
26impl SkillExecutor {
27    /// Run a skill executable with the given arguments.
28    ///
29    /// Returns a [`ToolResult`] derived from the process's stdout.
30    /// Errors from spawn, timeout, or invalid JSON on stdout produce
31    /// `ToolResult::Error`.
32    pub async fn execute(
33        executable: &Path,
34        args: serde_json::Value,
35        skill_dir: &Path,
36        timeout: Duration,
37    ) -> ToolResult {
38        debug!(
39            exe = %executable.display(),
40            skill_dir = %skill_dir.display(),
41            "Spawning skill executable"
42        );
43
44        let mut child = match Command::new(executable)
45            .current_dir(skill_dir)
46            .stdin(std::process::Stdio::piped())
47            .stdout(std::process::Stdio::piped())
48            .stderr(std::process::Stdio::piped())
49            .spawn()
50        {
51            Ok(c) => c,
52            Err(e) => {
53                error!(exe = %executable.display(), "Failed to spawn: {e}");
54                return ToolResult::err(format!(
55                    "Failed to spawn skill executable '{}': {}",
56                    executable.display(),
57                    e
58                ));
59            }
60        };
61
62        // Write JSON args to stdin.
63        if let Some(mut stdin) = child.stdin.take() {
64            let json_bytes = match serde_json::to_vec(&args) {
65                Ok(b) => b,
66                Err(e) => {
67                    return ToolResult::err(format!("Failed to serialise skill arguments: {}", e));
68                }
69            };
70
71            if let Err(e) = stdin.write_all(&json_bytes).await {
72                return ToolResult::err(format!("Failed to write to skill stdin: {}", e));
73            }
74            if let Err(e) = stdin.shutdown().await {
75                warn!("Failed to close skill stdin: {e}");
76            }
77        }
78
79        // Wait for the process with a timeout.
80        let result = match tokio::time::timeout(timeout, child.wait_with_output()).await {
81            Ok(Ok(output)) => output,
82            Ok(Err(e)) => {
83                error!(exe = %executable.display(), "Process error: {e}");
84                return ToolResult::err(format!("Skill process error: {}", e));
85            }
86            Err(_elapsed) => {
87                // Timeout – the child was moved into wait_with_output and dropped,
88                // which implicitly kills the process on tokio::process::Child drop.
89                return ToolResult::err(format!(
90                    "Skill '{}' timed out after {:?}",
91                    executable.display(),
92                    timeout
93                ));
94            }
95        };
96
97        // Log stderr.
98        if !result.stderr.is_empty() {
99            let stderr_str = String::from_utf8_lossy(&result.stderr);
100            if !result.status.success() {
101                warn!(
102                    skill = %executable.display(),
103                    "stderr (exit {:?}): {}",
104                    result.status.code(),
105                    stderr_str
106                );
107            } else {
108                debug!(
109                    skill = %executable.display(),
110                    "stderr: {}",
111                    stderr_str
112                );
113            }
114        }
115
116        // Parse stdout as ToolResult JSON.
117        let stdout_str = String::from_utf8_lossy(&result.stdout);
118
119        if stdout_str.trim().is_empty() {
120            return if result.status.success() {
121                ToolResult::ok(serde_json::Value::Null)
122            } else {
123                ToolResult::err(format!(
124                    "Skill exited with {:?} and produced no output",
125                    result.status.code()
126                ))
127            };
128        }
129
130        match serde_json::from_str::<ToolResult>(&stdout_str) {
131            Ok(tool_result) => tool_result,
132            Err(e) => {
133                warn!(
134                    skill = %executable.display(),
135                    "Stdout is not valid ToolResult JSON: {e}. Raw stdout: {stdout_str}"
136                );
137                // Return raw output as data so the LLM can still see it.
138                ToolResult::ok(serde_json::json!({
139                    "raw_output": stdout_str.trim().to_string(),
140                    "parse_warning": "Executable output was not valid ToolResult JSON"
141                }))
142            }
143        }
144    }
145}