Skip to main content

phi_core/agent_loop/
script_callback.rs

1//! Script-based callbacks — run shell or Python scripts as lifecycle hooks.
2//!
3//! `ScriptCallback` wraps an external script that receives JSON on stdin and
4//! returns JSON on stdout. The builder uses this to bridge config-specified
5//! script paths into Rust closure callbacks.
6//!
7//! # Protocol
8//!
9//! **Input (stdin):**
10//! ```json
11//! { "hook": "before_loop", "session_id": "...", "loop_id": "...", ... }
12//! ```
13//!
14//! **Output (stdout) for `before_*` hooks:**
15//! ```json
16//! { "allow": true }
17//! ```
18//!
19//! **Output (stdout) for `after_*` hooks:**
20//! Any JSON (logged but not acted upon).
21
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25/// A callback that executes an external script (shell or Python).
26#[derive(Debug, Clone)]
27pub struct ScriptCallback {
28    /// Path to the script (.sh, .py, or any executable).
29    pub path: PathBuf,
30    /// Working directory for script execution.
31    pub working_dir: Option<PathBuf>,
32}
33
34impl ScriptCallback {
35    /// Create a new script callback.
36    pub fn new(path: impl Into<PathBuf>, working_dir: Option<PathBuf>) -> Self {
37        Self {
38            path: path.into(),
39            working_dir,
40        }
41    }
42
43    /// Execute the script synchronously with JSON input on stdin.
44    /// Returns the parsed JSON output from stdout.
45    pub fn execute_sync(
46        &self,
47        input: &serde_json::Value,
48    ) -> Result<serde_json::Value, ScriptCallbackError> {
49        let input_json = serde_json::to_string(input)
50            .map_err(|e| ScriptCallbackError::Serialization(e.to_string()))?;
51
52        let interpreter = detect_interpreter(&self.path);
53        let mut cmd = Command::new(&interpreter[0]);
54        for arg in &interpreter[1..] {
55            cmd.arg(arg);
56        }
57        cmd.arg(&self.path);
58
59        if let Some(ref dir) = self.working_dir {
60            cmd.current_dir(dir);
61        }
62
63        cmd.stdin(std::process::Stdio::piped());
64        cmd.stdout(std::process::Stdio::piped());
65        cmd.stderr(std::process::Stdio::piped());
66
67        let mut child = cmd.spawn().map_err(|e| ScriptCallbackError::Spawn {
68            path: self.path.display().to_string(),
69            error: e.to_string(),
70        })?;
71
72        // Write input to stdin
73        if let Some(ref mut stdin) = child.stdin.take() {
74            use std::io::Write;
75            let _ = stdin.write_all(input_json.as_bytes());
76        }
77
78        let output = child
79            .wait_with_output()
80            .map_err(|e| ScriptCallbackError::Execution {
81                path: self.path.display().to_string(),
82                error: e.to_string(),
83            })?;
84
85        if !output.status.success() {
86            let stderr = String::from_utf8_lossy(&output.stderr);
87            return Err(ScriptCallbackError::NonZeroExit {
88                path: self.path.display().to_string(),
89                code: output.status.code(),
90                stderr: stderr.to_string(),
91            });
92        }
93
94        let stdout = String::from_utf8_lossy(&output.stdout);
95        serde_json::from_str(stdout.trim())
96            .map_err(|e| ScriptCallbackError::OutputParse(e.to_string()))
97    }
98}
99
100/// Detect the interpreter for a script based on extension.
101fn detect_interpreter(path: &Path) -> Vec<String> {
102    match path.extension().and_then(|e| e.to_str()) {
103        Some("py") => vec!["python3".into()],
104        Some("sh") => vec!["sh".into()],
105        _ => vec!["sh".into()], // default to shell
106    }
107}
108
109/// Returns true if a string looks like a script path (for config detection).
110pub fn is_script_path(s: &str) -> bool {
111    s.ends_with(".sh") || s.ends_with(".py") || s.contains('/')
112}
113
114/// Errors from script callback execution.
115#[derive(Debug)]
116pub enum ScriptCallbackError {
117    Spawn {
118        path: String,
119        error: String,
120    },
121    Execution {
122        path: String,
123        error: String,
124    },
125    NonZeroExit {
126        path: String,
127        code: Option<i32>,
128        stderr: String,
129    },
130    OutputParse(String),
131    Serialization(String),
132}
133
134impl std::fmt::Display for ScriptCallbackError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::Spawn { path, error } => write!(f, "Failed to spawn script {path}: {error}"),
138            Self::Execution { path, error } => {
139                write!(f, "Script execution failed {path}: {error}")
140            }
141            Self::NonZeroExit { path, code, stderr } => {
142                write!(f, "Script {path} exited with code {code:?}: {stderr}")
143            }
144            Self::OutputParse(e) => write!(f, "Failed to parse script output as JSON: {e}"),
145            Self::Serialization(e) => write!(f, "Failed to serialize input: {e}"),
146        }
147    }
148}
149
150impl std::error::Error for ScriptCallbackError {}