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, HookExecutor, HookHandler, HookResult};
42pub use events::{
43    ErrorType, GenerateEndEvent, GenerateStartEvent, HookEvent, HookEventType, OnErrorEvent,
44    PostResponseEvent, PostToolUseEvent, PrePromptEvent, PreToolUseEvent, SessionEndEvent,
45    SessionStartEvent, SkillLoadEvent, SkillUnloadEvent, TokenUsageInfo, ToolCallInfo,
46    ToolResultData,
47};
48pub use matcher::HookMatcher;
49
50/// Hook response action from SDK
51#[derive(Debug, Clone, PartialEq)]
52pub enum HookAction {
53    /// Proceed with execution (optionally with modifications)
54    Continue,
55    /// Block the operation
56    Block,
57    /// Retry after a delay
58    Retry,
59    /// Skip remaining hooks but continue execution
60    Skip,
61}
62
63/// Response from a hook handler
64#[derive(Debug, Clone)]
65pub struct HookResponse {
66    /// The hook ID this response is for
67    pub hook_id: String,
68    /// Action to take
69    pub action: HookAction,
70    /// Reason for blocking (if action is Block)
71    pub reason: Option<String>,
72    /// Modified data (if action is Continue with modifications)
73    pub modified: Option<serde_json::Value>,
74    /// Retry delay in milliseconds (if action is Retry)
75    pub retry_delay_ms: Option<u64>,
76}
77
78impl HookResponse {
79    /// Create a continue response
80    pub fn continue_() -> Self {
81        Self {
82            hook_id: String::new(),
83            action: HookAction::Continue,
84            reason: None,
85            modified: None,
86            retry_delay_ms: None,
87        }
88    }
89
90    /// Create a continue response with modifications
91    pub fn continue_with(modified: serde_json::Value) -> Self {
92        Self {
93            hook_id: String::new(),
94            action: HookAction::Continue,
95            reason: None,
96            modified: Some(modified),
97            retry_delay_ms: None,
98        }
99    }
100
101    /// Create a block response
102    pub fn block(reason: impl Into<String>) -> Self {
103        Self {
104            hook_id: String::new(),
105            action: HookAction::Block,
106            reason: Some(reason.into()),
107            modified: None,
108            retry_delay_ms: None,
109        }
110    }
111
112    /// Create a retry response
113    pub fn retry(delay_ms: u64) -> Self {
114        Self {
115            hook_id: String::new(),
116            action: HookAction::Retry,
117            reason: None,
118            modified: None,
119            retry_delay_ms: Some(delay_ms),
120        }
121    }
122
123    /// Create a skip response
124    pub fn skip() -> Self {
125        Self {
126            hook_id: String::new(),
127            action: HookAction::Skip,
128            reason: None,
129            modified: None,
130            retry_delay_ms: None,
131        }
132    }
133
134    /// Set the hook ID
135    pub fn with_hook_id(mut self, id: impl Into<String>) -> Self {
136        self.hook_id = id.into();
137        self
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_hook_response_continue() {
147        let response = HookResponse::continue_();
148        assert_eq!(response.action, HookAction::Continue);
149        assert!(response.reason.is_none());
150        assert!(response.modified.is_none());
151    }
152
153    #[test]
154    fn test_hook_response_continue_with_modified() {
155        let modified = serde_json::json!({"timeout": 5000});
156        let response = HookResponse::continue_with(modified.clone());
157        assert_eq!(response.action, HookAction::Continue);
158        assert_eq!(response.modified, Some(modified));
159    }
160
161    #[test]
162    fn test_hook_response_block() {
163        let response = HookResponse::block("Dangerous command");
164        assert_eq!(response.action, HookAction::Block);
165        assert_eq!(response.reason, Some("Dangerous command".to_string()));
166    }
167
168    #[test]
169    fn test_hook_response_retry() {
170        let response = HookResponse::retry(1000);
171        assert_eq!(response.action, HookAction::Retry);
172        assert_eq!(response.retry_delay_ms, Some(1000));
173    }
174
175    #[test]
176    fn test_hook_response_skip() {
177        let response = HookResponse::skip();
178        assert_eq!(response.action, HookAction::Skip);
179    }
180
181    #[test]
182    fn test_hook_response_with_hook_id() {
183        let response = HookResponse::continue_().with_hook_id("hook-123");
184        assert_eq!(response.hook_id, "hook-123");
185    }
186}