Skip to main content

agent_engine/extensions/hooks/
events.rs

1//! Hook events — typed payloads for each extension point.
2//!
3//! Each [`HookKind`] maps to a discrete phase of SynapsCLI's execution loop.
4//! [`HookEvent`] is the concrete payload dispatched to subscribers at that
5//! phase; [`HookResult`] is what a handler returns to control execution flow.
6//!
7//! Permission enforcement lives in [`crate::extensions::permissions`]:
8//! every `HookKind` declares a [`required_permission`][HookKind::required_permission]
9//! so the runtime can gate subscriptions before any payload is delivered.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::extensions::permissions::Permission;
15
16// ── HookKind ──────────────────────────────────────────────────────────────────
17
18/// All hook event kinds in the phase-1 catalog.
19///
20/// Each variant identifies a well-defined extension point in the agent loop.
21/// The set is intentionally closed; new kinds are added via a breaking version
22/// bump so existing permission grants stay coherent.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum HookKind {
26    /// Fires immediately before a tool is invoked. Handlers may block the call.
27    BeforeToolCall,
28    /// Fires immediately after a tool returns. Handlers receive the output.
29    AfterToolCall,
30    /// Fires before an LLM message is sent. Handlers may inspect or block.
31    BeforeMessage,
32    /// Fires after an assistant response is completed and added to history.
33    OnMessageComplete,
34    /// Fires after conversation compaction creates a replacement session.
35    OnCompaction,
36    /// Fires when a new session is created.
37    OnSessionStart,
38    /// Fires when a session is torn down.
39    OnSessionEnd,
40}
41
42impl HookKind {
43    /// Canonical string identifier for this kind, suitable for serialization
44    /// keys, log output, and manifest declarations.
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            Self::BeforeToolCall => "before_tool_call",
48            Self::AfterToolCall => "after_tool_call",
49            Self::BeforeMessage => "before_message",
50            Self::OnMessageComplete => "on_message_complete",
51            Self::OnCompaction => "on_compaction",
52            Self::OnSessionStart => "on_session_start",
53            Self::OnSessionEnd => "on_session_end",
54        }
55    }
56
57    /// Parse from the canonical string representation.
58    ///
59    /// Returns `None` for unrecognised strings so callers can surface
60    /// a manifest validation error rather than silently dropping hooks.
61    #[allow(clippy::should_implement_trait)]
62    pub fn from_str(s: &str) -> Option<Self> {
63        match s {
64            "before_tool_call" => Some(Self::BeforeToolCall),
65            "after_tool_call" => Some(Self::AfterToolCall),
66            "before_message" => Some(Self::BeforeMessage),
67            "on_message_complete" => Some(Self::OnMessageComplete),
68            "on_compaction" => Some(Self::OnCompaction),
69            "on_session_start" => Some(Self::OnSessionStart),
70            "on_session_end" => Some(Self::OnSessionEnd),
71            _ => None,
72        }
73    }
74
75    /// Supported action names for this hook in the extension contract.
76    pub fn allowed_action_names(&self) -> &'static [&'static str] {
77        match self {
78            Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
79            Self::AfterToolCall => &["continue"],
80            Self::BeforeMessage => &["continue", "inject"],
81            Self::OnMessageComplete | Self::OnCompaction | Self::OnSessionStart | Self::OnSessionEnd => &["continue"],
82        }
83    }
84
85    /// Whether this hook can be filtered by tool name in a manifest.
86    pub fn allows_tool_filter(&self) -> bool {
87        matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
88    }
89
90    /// Whether this hook accepts a handler result action.
91    pub fn allows_result(&self, result: &HookResult) -> bool {
92        matches!(
93            (self, result),
94            (_, HookResult::Continue)
95                | (Self::BeforeToolCall, HookResult::Block { .. })
96                | (Self::BeforeToolCall, HookResult::Confirm { .. })
97                | (Self::BeforeToolCall, HookResult::Modify { .. })
98                | (Self::BeforeMessage, HookResult::Inject { .. })
99        )
100    }
101
102    /// The [`Permission`] an extension must hold to subscribe to this hook.
103    ///
104    /// Called by the permission gate before delivering any event; if the
105    /// extension's [`PermissionSet`][crate::extensions::permissions::PermissionSet]
106    /// does not include this permission, `HookBus::subscribe()` returns an error.
107    pub fn required_permission(&self) -> Permission {
108        match self {
109            Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
110            Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => Permission::LlmContent,
111            Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
112        }
113    }
114}
115
116// ── HookEvent ─────────────────────────────────────────────────────────────────
117
118/// A hook event payload dispatched to extension handlers.
119///
120/// Fields are optional and populated only when relevant to the hook kind:
121///
122/// | Kind                  | tool_name | tool_input | tool_output | message | session_id |
123/// |-----------------------|-----------|------------|-------------|---------|------------|
124/// | `before_tool_call`    | ✓         | ✓          |             |         |            |
125/// | `after_tool_call`     | ✓         | ✓          | ✓           |         |            |
126/// | `before_message`      |           |            |             | ✓       |            |
127/// | `on_message_complete` |           |            |             | ✓       |            |
128/// | `on_compaction`       |           |            |             | ✓       | ✓          |
129/// | `on_session_start`    |           |            |             |         | ✓          |
130/// | `on_session_end`      |           |            |             |         | ✓          |
131///
132/// The `data` field is available on all events for extensions that need to
133/// attach arbitrary structured context when constructing synthetic events.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct HookEvent {
136    /// Which hook fired.
137    pub kind: HookKind,
138    /// Tool name for tool-specific hooks; `None` for general hooks.
139    /// This is the API-safe name (sanitized for the LLM).
140    pub tool_name: Option<String>,
141    /// Original runtime name of the tool (before API sanitization).
142    /// Extension authors typically write runtime names in their manifests.
143    #[serde(default)]
144    pub tool_runtime_name: Option<String>,
145    /// Tool input arguments for `before_tool_call` and `after_tool_call`.
146    pub tool_input: Option<Value>,
147    /// Tool output for `after_tool_call`.
148    pub tool_output: Option<String>,
149    /// LLM message content for `before_message`.
150    pub message: Option<String>,
151    /// Session identifier for session lifecycle hooks.
152    pub session_id: Option<String>,
153    /// Session message history for `on_session_end`.
154    /// Contains the conversation transcript so extensions (like Stelline)
155    /// can extract memories without reaching into runtime internals.
156    #[serde(default)]
157    pub transcript: Option<Vec<Value>>,
158    /// Arbitrary extension-defined data, passed through without inspection.
159    pub data: Value,
160}
161
162impl HookEvent {
163    /// Construct a `before_tool_call` event.
164    pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
165        Self {
166            kind: HookKind::BeforeToolCall,
167            tool_name: Some(tool_name.to_string()),
168            tool_input: Some(input),
169            tool_output: None,
170            message: None,
171            session_id: None,
172            tool_runtime_name: None,
173            transcript: None,
174            data: Value::Null,
175        }
176    }
177
178    /// Construct an `after_tool_call` event carrying both input and output.
179    /// Output is truncated to MAX_HOOK_OUTPUT_SIZE to prevent sending
180    /// megabytes of bash output over the JSON-RPC pipe.
181    pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
182        const MAX_HOOK_OUTPUT: usize = 32 * 1024; // 32 KB
183        let truncated_output = if output.len() > MAX_HOOK_OUTPUT {
184            let boundary = output
185                .char_indices()
186                .map(|(idx, _)| idx)
187                .take_while(|idx| *idx <= MAX_HOOK_OUTPUT)
188                .last()
189                .unwrap_or(0);
190            format!(
191                "{}…[truncated, {} total bytes]",
192                &output[..boundary],
193                output.len()
194            )
195        } else {
196            output
197        };
198        Self {
199            kind: HookKind::AfterToolCall,
200            tool_name: Some(tool_name.to_string()),
201            tool_input: Some(input),
202            tool_output: Some(truncated_output),
203            message: None,
204            session_id: None,
205            tool_runtime_name: None,
206            transcript: None,
207            data: Value::Null,
208        }
209    }
210
211    /// Construct a `before_message` event.
212    pub fn before_message(message: &str) -> Self {
213        Self {
214            kind: HookKind::BeforeMessage,
215            tool_name: None,
216            tool_input: None,
217            tool_output: None,
218            message: Some(message.to_string()),
219            session_id: None,
220            tool_runtime_name: None,
221            transcript: None,
222            data: Value::Null,
223        }
224    }
225
226    /// Construct an `on_message_complete` event.
227    pub fn on_message_complete(message: &str, data: Value) -> Self {
228        Self {
229            kind: HookKind::OnMessageComplete,
230            tool_name: None,
231            tool_input: None,
232            tool_output: None,
233            message: Some(message.to_string()),
234            session_id: None,
235            tool_runtime_name: None,
236            transcript: None,
237            data,
238        }
239    }
240
241    /// Construct an `on_compaction` event.
242    pub fn on_compaction(
243        old_session_id: &str,
244        new_session_id: &str,
245        summary: &str,
246        message_count: usize,
247        mut data: Value,
248    ) -> Self {
249        if !data.is_object() {
250            data = Value::Object(Default::default());
251        }
252        if let Some(object) = data.as_object_mut() {
253            object.insert("old_session_id".to_string(), Value::String(old_session_id.to_string()));
254            object.insert("new_session_id".to_string(), Value::String(new_session_id.to_string()));
255            object.insert("message_count".to_string(), Value::Number(message_count.into()));
256        }
257        Self {
258            kind: HookKind::OnCompaction,
259            tool_name: None,
260            tool_input: None,
261            tool_output: None,
262            message: Some(summary.to_string()),
263            session_id: Some(new_session_id.to_string()),
264            tool_runtime_name: None,
265            transcript: None,
266            data,
267        }
268    }
269
270    /// Construct an `on_session_start` event.
271    pub fn on_session_start(session_id: &str) -> Self {
272        Self {
273            kind: HookKind::OnSessionStart,
274            tool_name: None,
275            tool_input: None,
276            tool_output: None,
277            message: None,
278            session_id: Some(session_id.to_string()),
279            tool_runtime_name: None,
280            transcript: None,
281            data: Value::Null,
282        }
283    }
284
285    /// Construct an `on_session_end` event.
286    pub fn on_session_end(session_id: &str, transcript: Option<Vec<Value>>) -> Self {
287        Self {
288            kind: HookKind::OnSessionEnd,
289            tool_name: None,
290            tool_input: None,
291            tool_output: None,
292            message: None,
293            session_id: Some(session_id.to_string()),
294            tool_runtime_name: None,
295            transcript,
296            data: Value::Null,
297        }
298    }
299}
300
301// ── HookResult ────────────────────────────────────────────────────────────────
302
303/// What an extension handler returns after processing a hook event.
304///
305/// The runtime resolves multiple handlers by precedence:
306/// - `Block`, `Confirm`, and `Modify` stop the handler chain for `before_tool_call`.
307/// - `Inject` results are accumulated for `before_message`.
308/// - `Continue` is the no-op default — processing continues normally.
309#[derive(Debug, Clone, Default, Serialize, Deserialize)]
310#[serde(tag = "action", rename_all = "snake_case")]
311pub enum HookResult {
312    /// Allow execution to proceed unchanged.
313    #[default]
314    Continue,
315    /// Prevent the hooked operation. The `reason` is surfaced to the user.
316    Block { reason: String },
317    /// Inject context — the extension provides text to prepend to the
318    /// system prompt or conversation. Used by before_message hooks.
319    Inject { content: String },
320    /// Ask the runtime to get explicit user confirmation before proceeding.
321    /// Only valid on before_tool_call hooks.
322    Confirm { message: String },
323    /// Replace the tool input before execution. Only valid on before_tool_call hooks.
324    Modify { input: Value },
325}
326
327// ── Tests ─────────────────────────────────────────────────────────────────────
328
329impl HookEvent {
330    /// Set the runtime name for tool-related events.
331    pub fn with_runtime_name(mut self, name: &str) -> Self {
332        self.tool_runtime_name = Some(name.to_string());
333        self
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use serde_json::json;
341
342    // ── HookKind ──────────────────────────────────────────────────────────────
343
344    /// Every variant's as_str round-trips through from_str.
345    #[test]
346    fn hook_kind_as_str_roundtrip() {
347        let all = [
348            HookKind::BeforeToolCall,
349            HookKind::AfterToolCall,
350            HookKind::BeforeMessage,
351            HookKind::OnMessageComplete,
352            HookKind::OnCompaction,
353            HookKind::OnSessionStart,
354            HookKind::OnSessionEnd,
355        ];
356        for kind in all {
357            let s = kind.as_str();
358            assert_eq!(
359                HookKind::from_str(s),
360                Some(kind),
361                "round-trip failed for {s}"
362            );
363        }
364    }
365
366    /// Unknown strings return None, not a panic or a default.
367    #[test]
368    fn hook_kind_from_str_unknown_returns_none() {
369        assert_eq!(HookKind::from_str(""), None);
370        assert_eq!(HookKind::from_str("BeforeToolCall"), None); // wrong case
371        assert_eq!(HookKind::from_str("on_crash"), None);
372    }
373
374    /// Serde uses snake_case via the attribute — spot-check two variants.
375    #[test]
376    fn hook_kind_serde_snake_case() {
377        let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
378        assert_eq!(serialized, r#""before_tool_call""#);
379
380        let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
381        assert_eq!(back, HookKind::OnSessionEnd);
382    }
383
384    /// Each kind maps to the expected permission.
385    #[test]
386    fn hook_kind_required_permission() {
387        assert_eq!(
388            HookKind::BeforeToolCall.required_permission(),
389            Permission::ToolsIntercept
390        );
391        assert_eq!(
392            HookKind::AfterToolCall.required_permission(),
393            Permission::ToolsIntercept
394        );
395        assert_eq!(
396            HookKind::BeforeMessage.required_permission(),
397            Permission::LlmContent
398        );
399        assert_eq!(
400            HookKind::OnMessageComplete.required_permission(),
401            Permission::LlmContent
402        );
403        assert_eq!(
404            HookKind::OnCompaction.required_permission(),
405            Permission::LlmContent
406        );
407        assert_eq!(
408            HookKind::OnSessionStart.required_permission(),
409            Permission::SessionLifecycle
410        );
411        assert_eq!(
412            HookKind::OnSessionEnd.required_permission(),
413            Permission::SessionLifecycle
414        );
415    }
416
417    // ── HookEvent constructors ────────────────────────────────────────────────
418
419    #[test]
420    fn hook_event_before_tool_call() {
421        let input = json!({"path": "/tmp/foo"});
422        let ev = HookEvent::before_tool_call("read_file", input.clone());
423
424        assert_eq!(ev.kind, HookKind::BeforeToolCall);
425        assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
426        assert_eq!(ev.tool_input.as_ref(), Some(&input));
427        assert!(ev.tool_output.is_none());
428        assert!(ev.message.is_none());
429        assert!(ev.session_id.is_none());
430        assert_eq!(ev.data, Value::Null);
431    }
432
433    #[test]
434    fn hook_event_after_tool_call() {
435        let input = json!({"query": "select 1"});
436        let ev =
437            HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
438
439        assert_eq!(ev.kind, HookKind::AfterToolCall);
440        assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
441        assert_eq!(ev.tool_input.as_ref(), Some(&input));
442        assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
443        assert!(ev.message.is_none());
444        assert!(ev.session_id.is_none());
445    }
446
447    #[test]
448    fn hook_event_before_message() {
449        let ev = HookEvent::before_message("Hello, LLM");
450
451        assert_eq!(ev.kind, HookKind::BeforeMessage);
452        assert!(ev.tool_name.is_none());
453        assert!(ev.tool_input.is_none());
454        assert!(ev.tool_output.is_none());
455        assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
456        assert!(ev.session_id.is_none());
457    }
458
459    #[test]
460    fn hook_event_on_message_complete() {
461        let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
462
463        assert_eq!(ev.kind, HookKind::OnMessageComplete);
464        assert!(ev.tool_name.is_none());
465        assert!(ev.tool_input.is_none());
466        assert!(ev.tool_output.is_none());
467        assert_eq!(ev.message.as_deref(), Some("Done"));
468        assert_eq!(ev.data["content_block_count"], 1);
469        assert!(ev.session_id.is_none());
470    }
471
472    #[test]
473    fn hook_event_on_compaction() {
474        let ev = HookEvent::on_compaction(
475            "old-session",
476            "new-session",
477            "Summary",
478            7,
479            json!({"source": "manual"}),
480        );
481
482        assert_eq!(ev.kind, HookKind::OnCompaction);
483        assert_eq!(ev.message.as_deref(), Some("Summary"));
484        assert_eq!(ev.session_id.as_deref(), Some("new-session"));
485        assert_eq!(ev.data["old_session_id"], "old-session");
486        assert_eq!(ev.data["new_session_id"], "new-session");
487        assert_eq!(ev.data["message_count"], 7);
488        assert_eq!(ev.data["source"], "manual");
489        assert!(ev.transcript.is_none());
490    }
491
492    #[test]
493    fn hook_event_on_session_start() {
494        let ev = HookEvent::on_session_start("sess-abc-123");
495
496        assert_eq!(ev.kind, HookKind::OnSessionStart);
497        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
498        assert!(ev.tool_name.is_none());
499        assert!(ev.message.is_none());
500    }
501
502    #[test]
503    fn hook_event_on_session_end() {
504        let ev = HookEvent::on_session_end("sess-abc-123", None);
505
506        assert_eq!(ev.kind, HookKind::OnSessionEnd);
507        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
508        assert!(ev.tool_name.is_none());
509        assert!(ev.message.is_none());
510    }
511
512    /// HookEvent is round-trippable through JSON without loss.
513    #[test]
514    fn hook_event_serde_roundtrip() {
515        let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
516        let json = serde_json::to_string(&ev).unwrap();
517        let back: HookEvent = serde_json::from_str(&json).unwrap();
518
519        assert_eq!(back.kind, ev.kind);
520        assert_eq!(back.tool_name, ev.tool_name);
521        assert_eq!(back.tool_input, ev.tool_input);
522    }
523
524    // ── HookResult ────────────────────────────────────────────────────────────
525
526    /// Default is Continue.
527    #[test]
528    fn hook_result_default_is_continue() {
529        assert!(matches!(HookResult::default(), HookResult::Continue));
530    }
531
532    /// Block carries its reason through serialization.
533    #[test]
534    fn hook_result_block_serde() {
535        let r = HookResult::Block {
536            reason: "denied by policy".to_string(),
537        };
538        let json = serde_json::to_string(&r).unwrap();
539        // `tag = "action"` means the JSON object has {"action":"block","reason":"..."}
540        assert!(json.contains(r#""action":"block""#));
541        assert!(json.contains("denied by policy"));
542
543        let back: HookResult = serde_json::from_str(&json).unwrap();
544        assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
545    }
546
547    /// Confirm carries its message through serialization.
548    #[test]
549    fn hook_result_confirm_serde() {
550        let r = HookResult::Confirm {
551            message: "Run this command?".to_string(),
552        };
553        let json = serde_json::to_string(&r).unwrap();
554        assert_eq!(json, r#"{"action":"confirm","message":"Run this command?"}"#);
555
556        let back: HookResult = serde_json::from_str(&json).unwrap();
557        assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
558    }
559
560    /// Modify carries replacement input through serialization.
561    #[test]
562    fn hook_result_modify_serde() {
563        let r = HookResult::Modify { input: json!({"command": "echo safe"}) };
564        let json = serde_json::to_string(&r).unwrap();
565        assert_eq!(json, r#"{"action":"modify","input":{"command":"echo safe"}}"#);
566
567        let back: HookResult = serde_json::from_str(&json).unwrap();
568        assert!(matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"})));
569    }
570
571    /// Continue serialises as {"action":"continue"}.
572    #[test]
573    fn hook_result_continue_serde() {
574        let json = serde_json::to_string(&HookResult::Continue).unwrap();
575        assert_eq!(json, r#"{"action":"continue"}"#);
576    }
577}