Skip to main content

a3s_code_core/hooks/
mod.rs

1//! Hooks System for A3S Code Agent
2//!
3//! Provides a mechanism to intercept and customize agent behavior at various
4//! lifecycle points. Hooks can validate, transform, or block operations.
5//!
6//! ## Hook Events
7//!
8//! - `PreToolUse`: Before tool execution (can block/modify)
9//! - `PostToolUse`: After tool execution (fire-and-forget)
10//! - `GenerateStart`: Before LLM generation
11//! - `GenerateEnd`: After LLM generation
12//! - `SessionStart`: When session is created
13//! - `SessionEnd`: When session is destroyed
14//!
15//! ## Example
16//!
17//! ```ignore
18//! let engine = HookEngine::new();
19//!
20//! // Register a hook
21//! engine.register(Hook {
22//!     id: "security-check".to_string(),
23//!     event_type: HookEventType::PreToolUse,
24//!     matcher: Some(HookMatcher::tool("Bash")),
25//!     config: HookConfig::default(),
26//! });
27//!
28//! // Fire hook and get result
29//! let result = engine.fire(HookEvent::PreToolUse { ... }).await;
30//! match result {
31//!     HookResult::Continue(None) => { /* proceed */ }
32//!     HookResult::Continue(Some(modified)) => { /* proceed with modified data */ }
33//!     HookResult::Block(reason) => { /* stop execution */ }
34//! }
35//! ```
36
37mod engine;
38mod events;
39mod matcher;
40
41pub use engine::{Hook, HookConfig, HookEngine, HookHandler, HookResult};
42pub use events::{
43    GenerateEndEvent, GenerateStartEvent, HookEvent, HookEventType, PostToolUseEvent,
44    PreToolUseEvent, SessionEndEvent, SessionStartEvent, SkillLoadEvent, SkillUnloadEvent,
45    TokenUsageInfo, ToolCallInfo, ToolResultData,
46};
47pub use matcher::HookMatcher;
48
49/// Hook response action from SDK
50#[derive(Debug, Clone, PartialEq)]
51pub enum HookAction {
52    /// Proceed with execution (optionally with modifications)
53    Continue,
54    /// Block the operation
55    Block,
56    /// Retry after a delay
57    Retry,
58    /// Skip remaining hooks but continue execution
59    Skip,
60}
61
62/// Response from a hook handler
63#[derive(Debug, Clone)]
64pub struct HookResponse {
65    /// The hook ID this response is for
66    pub hook_id: String,
67    /// Action to take
68    pub action: HookAction,
69    /// Reason for blocking (if action is Block)
70    pub reason: Option<String>,
71    /// Modified data (if action is Continue with modifications)
72    pub modified: Option<serde_json::Value>,
73    /// Retry delay in milliseconds (if action is Retry)
74    pub retry_delay_ms: Option<u64>,
75}
76
77impl HookResponse {
78    /// Create a continue response
79    pub fn continue_() -> Self {
80        Self {
81            hook_id: String::new(),
82            action: HookAction::Continue,
83            reason: None,
84            modified: None,
85            retry_delay_ms: None,
86        }
87    }
88
89    /// Create a continue response with modifications
90    pub fn continue_with(modified: serde_json::Value) -> Self {
91        Self {
92            hook_id: String::new(),
93            action: HookAction::Continue,
94            reason: None,
95            modified: Some(modified),
96            retry_delay_ms: None,
97        }
98    }
99
100    /// Create a block response
101    pub fn block(reason: impl Into<String>) -> Self {
102        Self {
103            hook_id: String::new(),
104            action: HookAction::Block,
105            reason: Some(reason.into()),
106            modified: None,
107            retry_delay_ms: None,
108        }
109    }
110
111    /// Create a retry response
112    pub fn retry(delay_ms: u64) -> Self {
113        Self {
114            hook_id: String::new(),
115            action: HookAction::Retry,
116            reason: None,
117            modified: None,
118            retry_delay_ms: Some(delay_ms),
119        }
120    }
121
122    /// Create a skip response
123    pub fn skip() -> Self {
124        Self {
125            hook_id: String::new(),
126            action: HookAction::Skip,
127            reason: None,
128            modified: None,
129            retry_delay_ms: None,
130        }
131    }
132
133    /// Set the hook ID
134    pub fn with_hook_id(mut self, id: impl Into<String>) -> Self {
135        self.hook_id = id.into();
136        self
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_hook_response_continue() {
146        let response = HookResponse::continue_();
147        assert_eq!(response.action, HookAction::Continue);
148        assert!(response.reason.is_none());
149        assert!(response.modified.is_none());
150    }
151
152    #[test]
153    fn test_hook_response_continue_with_modified() {
154        let modified = serde_json::json!({"timeout": 5000});
155        let response = HookResponse::continue_with(modified.clone());
156        assert_eq!(response.action, HookAction::Continue);
157        assert_eq!(response.modified, Some(modified));
158    }
159
160    #[test]
161    fn test_hook_response_block() {
162        let response = HookResponse::block("Dangerous command");
163        assert_eq!(response.action, HookAction::Block);
164        assert_eq!(response.reason, Some("Dangerous command".to_string()));
165    }
166
167    #[test]
168    fn test_hook_response_retry() {
169        let response = HookResponse::retry(1000);
170        assert_eq!(response.action, HookAction::Retry);
171        assert_eq!(response.retry_delay_ms, Some(1000));
172    }
173
174    #[test]
175    fn test_hook_response_skip() {
176        let response = HookResponse::skip();
177        assert_eq!(response.action, HookAction::Skip);
178    }
179
180    #[test]
181    fn test_hook_response_with_hook_id() {
182        let response = HookResponse::continue_().with_hook_id("hook-123");
183        assert_eq!(response.hook_id, "hook-123");
184    }
185}