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