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//! - `PrePlanning`: Before task planning/decomposition (can block)
15//! - `PostPlanning`: After a plan is generated or planning fails
16//!
17//! ## Example
18//!
19//! ```ignore
20//! let engine = HookEngine::new();
21//!
22//! // Register a hook
23//! engine.register(Hook {
24//!     id: "security-check".to_string(),
25//!     event_type: HookEventType::PreToolUse,
26//!     matcher: Some(HookMatcher::tool("Bash")),
27//!     config: HookConfig::default(),
28//! });
29//!
30//! // Fire hook and get result
31//! let result = engine.fire(HookEvent::PreToolUse { ... }).await;
32//! match result {
33//!     HookResult::Continue(None) => { /* proceed */ }
34//!     HookResult::Continue(Some(modified)) => { /* proceed with modified data */ }
35//!     HookResult::Block(reason) => { /* stop execution */ }
36//! }
37//! ```
38
39mod engine;
40mod events;
41mod matcher;
42
43pub use engine::{
44    Hook, HookConfig, HookEngine, HookExecutor, HookHandler, HookOutcome, HookResult,
45};
46pub use events::{
47    ConfirmationType, ErrorType, GenerateEndEvent, GenerateStartEvent, HookEvent, HookEventType,
48    IntentDetectionEvent, OnConfirmationEvent, OnErrorEvent, OnRateLimitEvent, OnSuccessEvent,
49    PlanningStrategy, PostContextPerceptionEvent, PostMemoryRecallEvent, PostPlanningEvent,
50    PostReasoningEvent, PostResponseEvent, PostToolUseEvent, PreContextPerceptionEvent,
51    PreMemoryRecallEvent, PrePlanningEvent, PrePromptEvent, PreReasoningEvent, PreToolUseEvent,
52    RateLimitType, ReasoningType, SessionEndEvent, SessionStartEvent, SkillLoadEvent,
53    SkillUnloadEvent, TokenUsageInfo, ToolCallInfo, ToolResultData,
54};
55pub use matcher::HookMatcher;
56
57/// Hook response action from SDK
58#[derive(Debug, Clone, PartialEq)]
59pub enum HookAction {
60    /// Proceed with execution (optionally with modifications)
61    Continue,
62    /// Block the operation
63    Block,
64    /// Retry after a delay
65    Retry,
66    /// Skip remaining hooks but continue execution
67    Skip,
68}
69
70/// Response from a hook handler
71#[derive(Debug, Clone)]
72pub struct HookResponse {
73    /// The hook ID this response is for
74    pub hook_id: String,
75    /// Action to take
76    pub action: HookAction,
77    /// Reason for blocking or retrying (if action is Block or Retry)
78    pub reason: Option<String>,
79    /// Modified data (if action is Continue with modifications)
80    pub modified: Option<serde_json::Value>,
81    /// Retry delay in milliseconds (if action is Retry)
82    pub retry_delay_ms: Option<u64>,
83}
84
85impl HookResponse {
86    /// Create a continue response
87    pub fn continue_() -> Self {
88        Self {
89            hook_id: String::new(),
90            action: HookAction::Continue,
91            reason: None,
92            modified: None,
93            retry_delay_ms: None,
94        }
95    }
96
97    /// Create a continue response with modifications
98    pub fn continue_with(modified: serde_json::Value) -> Self {
99        Self {
100            hook_id: String::new(),
101            action: HookAction::Continue,
102            reason: None,
103            modified: Some(modified),
104            retry_delay_ms: None,
105        }
106    }
107
108    /// Create a block response
109    pub fn block(reason: impl Into<String>) -> Self {
110        Self {
111            hook_id: String::new(),
112            action: HookAction::Block,
113            reason: Some(reason.into()),
114            modified: None,
115            retry_delay_ms: None,
116        }
117    }
118
119    /// Create a retry response
120    pub fn retry(delay_ms: u64) -> Self {
121        Self {
122            hook_id: String::new(),
123            action: HookAction::Retry,
124            reason: None,
125            modified: None,
126            retry_delay_ms: Some(delay_ms),
127        }
128    }
129
130    /// Create a retry response with actionable context.
131    pub fn retry_with_reason(reason: impl Into<String>, delay_ms: u64) -> Self {
132        Self {
133            hook_id: String::new(),
134            action: HookAction::Retry,
135            reason: Some(reason.into()),
136            modified: None,
137            retry_delay_ms: Some(delay_ms),
138        }
139    }
140
141    /// Create a skip response
142    pub fn skip() -> Self {
143        Self {
144            hook_id: String::new(),
145            action: HookAction::Skip,
146            reason: None,
147            modified: None,
148            retry_delay_ms: None,
149        }
150    }
151
152    /// Set the hook ID
153    pub fn with_hook_id(mut self, id: impl Into<String>) -> Self {
154        self.hook_id = id.into();
155        self
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_hook_response_continue() {
165        let response = HookResponse::continue_();
166        assert_eq!(response.action, HookAction::Continue);
167        assert!(response.reason.is_none());
168        assert!(response.modified.is_none());
169    }
170
171    #[test]
172    fn test_hook_response_continue_with_modified() {
173        let modified = serde_json::json!({"timeout": 5000});
174        let response = HookResponse::continue_with(modified.clone());
175        assert_eq!(response.action, HookAction::Continue);
176        assert_eq!(response.modified, Some(modified));
177    }
178
179    #[test]
180    fn test_hook_response_block() {
181        let response = HookResponse::block("Dangerous command");
182        assert_eq!(response.action, HookAction::Block);
183        assert_eq!(response.reason, Some("Dangerous command".to_string()));
184    }
185
186    #[test]
187    fn test_hook_response_retry() {
188        let response = HookResponse::retry(1000);
189        assert_eq!(response.action, HookAction::Retry);
190        assert_eq!(response.retry_delay_ms, Some(1000));
191    }
192
193    #[test]
194    fn test_hook_response_retry_with_reason() {
195        let response = HookResponse::retry_with_reason("temporary outage", 750);
196        assert_eq!(response.action, HookAction::Retry);
197        assert_eq!(response.reason.as_deref(), Some("temporary outage"));
198        assert_eq!(response.retry_delay_ms, Some(750));
199    }
200
201    #[test]
202    fn test_hook_response_skip() {
203        let response = HookResponse::skip();
204        assert_eq!(response.action, HookAction::Skip);
205    }
206
207    #[test]
208    fn test_hook_response_with_hook_id() {
209        let response = HookResponse::continue_().with_hook_id("hook-123");
210        assert_eq!(response.hook_id, "hook-123");
211    }
212}