Skip to main content

rho_core/
hooks.rs

1use std::time::Duration;
2use async_trait::async_trait;
3use tokio_util::sync::CancellationToken;
4
5/// Result of a post-tools hook execution
6#[derive(Debug, Clone)]
7pub struct PostToolsHookResult {
8    /// If set, injected as a User message before next LLM call
9    pub steering_message: Option<String>,
10    pub success: bool,
11    pub summary: String,
12}
13
14/// Hook that runs after tool execution, before returning to LLM
15#[async_trait]
16pub trait PostToolsHook: Send + Sync {
17    fn name(&self) -> &str;
18    async fn execute(&self, tool_names_called: &[String], cancel: CancellationToken) -> PostToolsHookResult;
19    fn timeout(&self) -> Duration {
20        Duration::from_secs(30)
21    }
22}
23
24/// Configuration for a shell-command hook loaded from RHO.md
25#[derive(Debug, Clone)]
26pub struct HookConfig {
27    pub name: String,
28    pub command: String,
29    pub timeout: u64,
30    pub inject_on_failure: bool,
31    pub trigger_tools: Option<Vec<String>>,
32}
33
34/// Shell command hook implementation
35pub struct ShellCommandHook {
36    pub config: HookConfig,
37    pub cwd: std::path::PathBuf,
38}
39
40#[async_trait]
41impl PostToolsHook for ShellCommandHook {
42    fn name(&self) -> &str {
43        &self.config.name
44    }
45
46    fn timeout(&self) -> Duration {
47        Duration::from_secs(self.config.timeout)
48    }
49
50    async fn execute(&self, tool_names_called: &[String], cancel: CancellationToken) -> PostToolsHookResult {
51        // Check trigger_tools filter
52        if let Some(ref triggers) = self.config.trigger_tools {
53            let any_match = tool_names_called.iter().any(|t| triggers.contains(t));
54            if !any_match {
55                return PostToolsHookResult {
56                    steering_message: None,
57                    success: true,
58                    summary: format!("{}: skipped (no matching tools)", self.config.name),
59                };
60            }
61        }
62
63        let result = tokio::select! {
64            result = tokio::process::Command::new("sh")
65                .arg("-c")
66                .arg(&self.config.command)
67                .current_dir(&self.cwd)
68                .output() => result,
69            _ = cancel.cancelled() => {
70                return PostToolsHookResult {
71                    steering_message: None,
72                    success: false,
73                    summary: format!("{}: cancelled", self.config.name),
74                };
75            }
76        };
77
78        match result {
79            Ok(output) => {
80                let success = output.status.success();
81                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
82                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
83
84                let steering = if !success && self.config.inject_on_failure {
85                    let mut msg = format!("[hook:{}] FAILED (exit {})\n", self.config.name, output.status.code().unwrap_or(-1));
86                    if !stdout.is_empty() {
87                        msg.push_str(&format!("stdout:\n{}\n", stdout.trim()));
88                    }
89                    if !stderr.is_empty() {
90                        msg.push_str(&format!("stderr:\n{}\n", stderr.trim()));
91                    }
92                    Some(msg)
93                } else {
94                    None
95                };
96
97                let summary = if success {
98                    format!("{}: ok", self.config.name)
99                } else {
100                    format!("{}: failed (exit {})", self.config.name, output.status.code().unwrap_or(-1))
101                };
102
103                PostToolsHookResult {
104                    steering_message: steering,
105                    success,
106                    summary,
107                }
108            }
109            Err(e) => PostToolsHookResult {
110                steering_message: if self.config.inject_on_failure {
111                    Some(format!("[hook:{}] Failed to execute: {}", self.config.name, e))
112                } else {
113                    None
114                },
115                success: false,
116                summary: format!("{}: error ({})", self.config.name, e),
117            },
118        }
119    }
120}