From d9c6a38a1fff8e5f13efbef0cc01c3fad4a56b5c Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 16 Jun 2026 12:51:17 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(sdk):=20hook=20handler=20API=20?= =?UTF-8?q?=E2=80=94=20astrid=5Fsdk::hook=20module=20+=20#[hook]=20attribu?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add astrid_sdk::hook: HookEvent wrapper over the canonical HookEventRequest/HookResult contracts, with event_topic/response_topic helpers and read-then-optionally-reply ergonomics (payload, reply, skip, respond_data). Fire-and-forget hooks (no correlation id) reply as a no-op. Add #[hook("name")] capsule-macro attribute registering a fail-open dispatch arm keyed on the hook name: deserialize HookEventRequest, build HookEvent, call the user handler via the existing stateful (KV load/persist) / stateless (get_instance) instance pattern, and best-effort publish any returned HookResult. Malformed events and handler errors log a warning and return continue. --- astrid-sdk-macros/src/lib.rs | 174 +++++++++++++++++++ astrid-sdk/src/hook.rs | 287 +++++++++++++++++++++++++++++++ astrid-sdk/src/lib.rs | 19 ++ examples/test-capsule/src/lib.rs | 21 +++ 4 files changed, 501 insertions(+) create mode 100644 astrid-sdk/src/hook.rs diff --git a/astrid-sdk-macros/src/lib.rs b/astrid-sdk-macros/src/lib.rs index 88a507b..aafbb30 100644 --- a/astrid-sdk-macros/src/lib.rs +++ b/astrid-sdk-macros/src/lib.rs @@ -605,6 +605,104 @@ fn capsule_impl( } }); } + } else if attr_name == "hook" { + // #[hook("name")] subscribes to a semantic lifecycle hook. + // The method signature is + // fn(&self, event: &HookEvent) + // -> Result, SysError> + // The bridge delivers a JSON-encoded HookEventRequest as + // `payload`. We deserialize it, hand the typed HookEvent to + // the user method, then publish any returned HookResult on + // the scoped reply topic. Every path is fail-open: a bad + // event or handler error must not wedge the capsule, so we + // always return `continue`. + // + // The call expression mirrors the interceptor arm's + // stateful (load-from-KV `instance`, persist after) vs + // stateless (`get_instance()`) instance handling so #[hook] + // works for both kinds of capsule. + let hook_call = if is_stateful { + quote! { instance.#method_name(&event) } + } else { + quote! { get_instance().#method_name(&event) } + }; + + let dispatch = quote! { + match #hook_call { + Ok(Some(result)) => { + if let Err(e) = event.reply(result) { + ::astrid_sdk::prelude::log::warn( + format!("hook '{}': failed to reply: {}", #name_val, e), + ); + } + } + Ok(None) => {} + Err(e) => { + ::astrid_sdk::prelude::log::warn( + format!("hook '{}': handler error: {}", #name_val, e), + ); + } + } + }; + + // Wrap the dispatch in the same state lifecycle the + // interceptor path uses: load `instance` from KV before the + // call, persist it back after, for stateful capsules. + let hook_block = if is_stateful { + quote! { + let mut instance: #struct_name = match ::astrid_sdk::prelude::kv::get_json("__state") { + Ok(state) => state, + Err(::astrid_sdk::SysError::JsonError(_)) => Default::default(), + Err(e) => { + ::astrid_sdk::prelude::log::warn( + format!("hook '{}': failed to load state: {}", #name_val, e), + ); + return ::astrid_sdk::astrid_sys::CapsuleResult { + action: "continue".into(), + data: None, + }; + } + }; + #dispatch + if let Err(e) = ::astrid_sdk::prelude::kv::set_json("__state", &instance) { + ::astrid_sdk::prelude::log::warn( + format!("hook '{}': failed to save state: {}", #name_val, e), + ); + } + return ::astrid_sdk::astrid_sys::CapsuleResult { + action: "continue".into(), + data: None, + }; + } + } else { + quote! { + #dispatch + return ::astrid_sdk::astrid_sys::CapsuleResult { + action: "continue".into(), + data: None, + }; + } + }; + + hook_arms.push(quote! { + #name_val => { + let req: ::astrid_sdk::hook::HookEventRequest = + match ::serde_json::from_slice(&payload) { + Ok(req) => req, + Err(e) => { + ::astrid_sdk::prelude::log::warn( + format!("hook '{}': malformed event: {}", #name_val, e), + ); + return ::astrid_sdk::astrid_sys::CapsuleResult { + action: "continue".into(), + data: None, + }; + } + }; + let event = ::astrid_sdk::hook::HookEvent::from_request(req); + #hook_block + } + }); } else if attr_name == "command" { command_arms.push(quote! { #name_val => { #execute_block } @@ -1013,6 +1111,82 @@ mod tests { ); } + #[test] + fn hook_arm_generated_stateless() { + // A stateless #[hook] method must register a match arm keyed on the + // hook name that deserializes a HookEventRequest, dispatches via + // get_instance(), and replies — all fail-open ("continue"). + let attr = quote::quote! {}; + let input = quote::quote! { + impl MyCapsule { + #[astrid::hook("before_tool_call")] + fn before_tool_call( + &self, + event: &astrid_sdk::hook::HookEvent, + ) -> Result, astrid_sdk::SysError> { + todo!() + } + } + }; + let output = capsule_impl(attr, input).to_string(); + assert!( + output.contains("\"before_tool_call\""), + "Hook arm must key on the hook name, got:\n{output}" + ); + assert!( + output.contains("HookEventRequest") && output.contains("from_request"), + "Hook arm must deserialize a HookEventRequest and build a HookEvent, got:\n{output}" + ); + assert!( + output.contains("get_instance ()"), + "Stateless hook arm must dispatch via get_instance(), got:\n{output}" + ); + assert!( + output.contains("event . reply (result)"), + "Hook arm must reply with the returned HookResult, got:\n{output}" + ); + assert!( + output.contains("\"continue\""), + "Hook arm must be fail-open (continue), got:\n{output}" + ); + } + + #[test] + fn hook_arm_stateful_persists_state() { + // A #[hook] method on a stateful capsule (some &mut self method present) + // must load state from KV before the call and persist it after, like the + // interceptor path. + let attr = quote::quote! {}; + let input = quote::quote! { + impl MyCapsule { + #[astrid::hook("session_start")] + fn session_start( + &self, + event: &astrid_sdk::hook::HookEvent, + ) -> Result, astrid_sdk::SysError> { + todo!() + } + #[astrid::tool("bump")] + fn bump(&mut self, args: BumpArgs) -> Result { + todo!() + } + } + }; + let output = capsule_impl(attr, input).to_string(); + assert!( + output.contains("get_json (\"__state\")"), + "Stateful hook arm must load state from KV, got:\n{output}" + ); + assert!( + output.contains("set_json (\"__state\""), + "Stateful hook arm must persist state to KV, got:\n{output}" + ); + assert!( + output.contains("instance . session_start (& event)"), + "Stateful hook arm must dispatch on the loaded instance, got:\n{output}" + ); + } + #[test] fn inline_mutable_name_inferred() { let attr = quote::quote! {}; diff --git a/astrid-sdk/src/hook.rs b/astrid-sdk/src/hook.rs new file mode 100644 index 0000000..ca07a0b --- /dev/null +++ b/astrid-sdk/src/hook.rs @@ -0,0 +1,287 @@ +//! Lifecycle hook handling — read an event, optionally reply. +//! +//! The hook bridge translates kernel lifecycle events (tool calls, +//! session start/end, compaction, …) into semantic hooks and fans them +//! out to subscriber capsules over IPC. A capsule that subscribes to a +//! hook receives a [`HookEventRequest`] on `hook.v1.event.` and — +//! when it wishes to influence the outcome — replies with a +//! [`HookResult`] on the per-request scoped topic +//! `hook.v1.response..`. +//! +//! # The read-then-optionally-reply model +//! +//! Every hook delivery is a *read*: the capsule inspects the event and +//! decides whether to act. Replying is optional. Two reply shapes exist: +//! +//! - [`HookEvent::skip`] — ask the kernel to skip the gated operation +//! (e.g. veto a tool call before it runs). +//! - [`HookEvent::respond_data`] — supply merged data the kernel folds +//! back into the operation (opaque JSON, interpreted per hook). +//! +//! Some hooks are **fire-and-forget**: the bridge dispatches them with no +//! correlation id, so there is no reply topic. For those, [`HookEvent::reply`] +//! (and `skip` / `respond_data`) are no-ops that succeed without publishing — +//! the capsule observes the event but cannot influence it. +//! +//! Capsule authors rarely call this module directly. The `#[hook("name")]` +//! attribute on a `#[capsule]` method wires the deserialize → dispatch → +//! reply plumbing automatically; this module is the typed surface those +//! handlers receive. + +pub use crate::contracts::hook::{HookEventRequest, HookResult}; + +/// Topic prefix for hook event envelopes published by the hook bridge. +/// +/// The full topic is [`EVENT_PREFIX`] concatenated with the hook name, +/// e.g. `hook.v1.event.before_tool_call`. See [`event_topic`]. +pub const EVENT_PREFIX: &str = "hook.v1.event."; + +/// Topic prefix for per-request hook reply envelopes. +/// +/// The full topic is [`RESPONSE_PREFIX`] concatenated with the hook name +/// and the request's correlation id, e.g. +/// `hook.v1.response.before_tool_call.`. See [`response_topic`]. +pub const RESPONSE_PREFIX: &str = "hook.v1.response."; + +/// Build the event topic a subscriber listens on for hook `name`. +/// +/// ``` +/// # use astrid_sdk::hook::event_topic; +/// assert_eq!(event_topic("session_start"), "hook.v1.event.session_start"); +/// ``` +#[must_use] +pub fn event_topic(name: &str) -> String { + format!("{EVENT_PREFIX}{name}") +} + +/// Build the scoped reply topic for hook `name` and `correlation_id`. +/// +/// ``` +/// # use astrid_sdk::hook::response_topic; +/// assert_eq!( +/// response_topic("before_tool_call", "abc-123"), +/// "hook.v1.response.before_tool_call.abc-123", +/// ); +/// ``` +#[must_use] +pub fn response_topic(name: &str, correlation_id: &str) -> String { + format!("{RESPONSE_PREFIX}{name}.{correlation_id}") +} + +/// A hook event delivered to a subscriber, with its reply channel bound. +/// +/// Wraps a [`HookEventRequest`] and exposes ergonomic accessors plus the +/// reply helpers ([`reply`](Self::reply), [`skip`](Self::skip), +/// [`respond_data`](Self::respond_data)). The payload is opaque JSON whose +/// shape depends on the hook — use [`payload`](Self::payload) to +/// deserialize it into a concrete type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookEvent { + hook: String, + correlation_id: Option, + payload: String, +} + +impl HookEvent { + /// Build a [`HookEvent`] from the raw [`HookEventRequest`] envelope, + /// taking ownership of its fields. + #[must_use] + pub fn from_request(req: HookEventRequest) -> Self { + Self { + hook: req.hook, + correlation_id: req.correlation_id, + payload: req.payload, + } + } + + /// The semantic hook name (e.g. `before_tool_call`, `session_start`). + #[must_use] + pub fn name(&self) -> &str { + &self.hook + } + + /// The per-request correlation id, or `None` for fire-and-forget + /// hooks that cannot be replied to. + #[must_use] + pub fn correlation_id(&self) -> Option<&str> { + self.correlation_id.as_deref() + } + + /// The raw, opaque JSON payload as delivered by the bridge. + /// + /// Prefer [`payload`](Self::payload) when the concrete shape is known. + #[must_use] + pub fn payload_str(&self) -> &str { + &self.payload + } + + /// Deserialize the JSON payload into a concrete type. + /// + /// # Errors + /// + /// Returns [`crate::SysError::HostError`] if the payload is not valid + /// JSON for `T`. + pub fn payload(&self) -> Result { + ::serde_json::from_str(&self.payload).map_err(|e| crate::SysError::HostError(e.to_string())) + } + + /// Publish a [`HookResult`] on this event's scoped reply topic. + /// + /// For fire-and-forget hooks (no correlation id) this is a successful + /// no-op — there is no reply topic, so nothing is published. + /// + /// # Errors + /// + /// Returns [`crate::SysError`] if the underlying IPC publish fails. + pub fn reply(&self, result: HookResult) -> Result<(), crate::SysError> { + match &self.correlation_id { + Some(id) => crate::ipc::publish_json(&response_topic(&self.hook, id), &result), + None => Ok(()), + } + } + + /// Ask the kernel to skip the gated operation. + /// + /// Shorthand for [`reply`](Self::reply) with `skip: Some(true)`. + /// + /// # Errors + /// + /// Returns [`crate::SysError`] if the underlying IPC publish fails. + pub fn skip(&self) -> Result<(), crate::SysError> { + self.reply(HookResult { + skip: Some(true), + data: None, + }) + } + + /// Reply with merged data the kernel folds back into the operation. + /// + /// Shorthand for [`reply`](Self::reply) with `data: Some(data)`. + /// + /// # Errors + /// + /// Returns [`crate::SysError`] if the underlying IPC publish fails. + pub fn respond_data(&self, data: impl Into) -> Result<(), crate::SysError> { + self.reply(HookResult { + skip: None, + data: Some(data.into()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_topic_formats() { + assert_eq!(event_topic("session_start"), "hook.v1.event.session_start"); + assert_eq!(EVENT_PREFIX, "hook.v1.event."); + } + + #[test] + fn response_topic_formats() { + assert_eq!( + response_topic("before_tool_call", "abc-123"), + "hook.v1.response.before_tool_call.abc-123", + ); + assert_eq!(RESPONSE_PREFIX, "hook.v1.response."); + } + + #[test] + fn from_request_moves_fields() { + let event = HookEvent::from_request(HookEventRequest { + hook: "before_tool_call".into(), + payload: r#"{"tool":"shell"}"#.into(), + correlation_id: Some("corr-1".into()), + }); + assert_eq!(event.name(), "before_tool_call"); + assert_eq!(event.correlation_id(), Some("corr-1")); + assert_eq!(event.payload_str(), r#"{"tool":"shell"}"#); + } + + #[test] + fn from_request_fire_and_forget_has_no_correlation_id() { + let event = HookEvent::from_request(HookEventRequest { + hook: "session_end".into(), + payload: "{}".into(), + correlation_id: None, + }); + assert_eq!(event.correlation_id(), None); + } + + #[derive(::serde::Deserialize, Debug, PartialEq, Eq)] + struct ToolPayload { + tool: String, + } + + #[test] + fn payload_deserializes() { + let event = HookEvent::from_request(HookEventRequest { + hook: "before_tool_call".into(), + payload: r#"{"tool":"shell"}"#.into(), + correlation_id: None, + }); + let parsed: ToolPayload = event.payload().unwrap(); + assert_eq!( + parsed, + ToolPayload { + tool: "shell".into() + } + ); + } + + #[test] + fn payload_malformed_is_host_error() { + let event = HookEvent::from_request(HookEventRequest { + hook: "before_tool_call".into(), + payload: "not json".into(), + correlation_id: None, + }); + let err = event.payload::().unwrap_err(); + assert!(matches!(err, crate::SysError::HostError(_))); + } + + // The reply helpers publish over IPC, which requires a host. We assert + // the no-host-needed shape they build instead: fire-and-forget replies + // succeed without touching the bus. Wire-publish behaviour is covered by + // integration tests against a running kernel. + #[test] + fn reply_fire_and_forget_is_noop_ok() { + let event = HookEvent::from_request(HookEventRequest { + hook: "session_end".into(), + payload: "{}".into(), + correlation_id: None, + }); + assert!( + event + .reply(HookResult { + skip: Some(true), + data: None, + }) + .is_ok() + ); + assert!(event.skip().is_ok()); + assert!(event.respond_data("{}").is_ok()); + } + + #[test] + fn skip_builds_skip_result() { + let result = HookResult { + skip: Some(true), + data: None, + }; + assert_eq!(result.skip, Some(true)); + assert_eq!(result.data, None); + } + + #[test] + fn respond_data_builds_data_result() { + let result = HookResult { + skip: None, + data: Some(r#"{"k":"v"}"#.into()), + }; + assert_eq!(result.skip, None); + assert_eq!(result.data.as_deref(), Some(r#"{"k":"v"}"#)); + } +} diff --git a/astrid-sdk/src/lib.rs b/astrid-sdk/src/lib.rs index c57b8dc..e337045 100644 --- a/astrid-sdk/src/lib.rs +++ b/astrid-sdk/src/lib.rs @@ -513,6 +513,20 @@ pub mod capabilities { pub mod net; pub mod process; +/// Lifecycle hook handling — read kernel lifecycle events, optionally reply. +/// +/// The hook bridge fans semantic hooks (tool calls, session lifecycle, +/// compaction, …) out to subscriber capsules as [`hook::HookEvent`]s. A +/// subscriber inspects the event and may reply with a [`hook::HookResult`] +/// to skip the gated operation or merge data back into it. The +/// `#[hook("name")]` attribute on a `#[capsule]` method wires this up. +/// +/// Re-exports the canonical [`HookEventRequest`](hook::HookEventRequest) and +/// [`HookResult`](hook::HookResult) from [`contracts`], so it is only +/// available with the `derive` feature (enabled by default). +#[cfg(feature = "derive")] +pub mod hook; + /// The Elicit Airlock - User Input During Install/Upgrade Lifecycle /// /// These functions are only callable during `#[astrid::install]` and @@ -652,6 +666,11 @@ pub mod prelude { uplink, }; + // Hook handling re-exports the `derive`-gated `contracts` types, so the + // module is only available with the `derive` feature. + #[cfg(feature = "derive")] + pub use crate::hook; + #[cfg(feature = "derive")] pub use astrid_sdk_macros::capsule; diff --git a/examples/test-capsule/src/lib.rs b/examples/test-capsule/src/lib.rs index c52f5ad..beb20bc 100644 --- a/examples/test-capsule/src/lib.rs +++ b/examples/test-capsule/src/lib.rs @@ -59,6 +59,27 @@ impl TestCapsule { Ok(serde_json::json!({ "handled": true })) } + /// A lifecycle hook that inspects a tool-call event and may veto it. + /// + /// Exercises the `#[hook]` arm end-to-end: deserialize the payload, + /// inspect it, and reply with a [`hook::HookResult`] (here: skip the + /// call when the tool name is `forbidden`, otherwise pass through). + #[astrid::hook("before_tool_call")] + fn before_tool_call( + &self, + event: &hook::HookEvent, + ) -> Result, SysError> { + log::info(&format!("hook {} fired", event.name())); + let payload: serde_json::Value = event.payload()?; + if payload.get("tool").and_then(|t| t.as_str()) == Some("forbidden") { + return Ok(Some(hook::HookResult { + skip: Some(true), + data: None, + })); + } + Ok(None) + } + /// Lifecycle: first-time install. #[astrid::install] fn install(&self) -> Result<(), SysError> { From 80f4f1f300bd8baa0bd8424373aef47df9037a3f Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 16 Jun 2026 15:20:25 +0400 Subject: [PATCH 2/2] fix(sdk): hook payload() returns typed JsonError, not stringly HostError Map the serde error via SysError::from (the JsonError #[from] variant), matching kv::get_json and the rest of the SDK; update the test to assert SysError::JsonError. Addresses the Gemini review. --- astrid-sdk/src/hook.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/astrid-sdk/src/hook.rs b/astrid-sdk/src/hook.rs index ca07a0b..5d5a748 100644 --- a/astrid-sdk/src/hook.rs +++ b/astrid-sdk/src/hook.rs @@ -119,10 +119,10 @@ impl HookEvent { /// /// # Errors /// - /// Returns [`crate::SysError::HostError`] if the payload is not valid + /// Returns [`crate::SysError::JsonError`] if the payload is not valid /// JSON for `T`. pub fn payload(&self) -> Result { - ::serde_json::from_str(&self.payload).map_err(|e| crate::SysError::HostError(e.to_string())) + ::serde_json::from_str(&self.payload).map_err(crate::SysError::from) } /// Publish a [`HookResult`] on this event's scoped reply topic. @@ -232,14 +232,14 @@ mod tests { } #[test] - fn payload_malformed_is_host_error() { + fn payload_malformed_is_json_error() { let event = HookEvent::from_request(HookEventRequest { hook: "before_tool_call".into(), payload: "not json".into(), correlation_id: None, }); let err = event.payload::().unwrap_err(); - assert!(matches!(err, crate::SysError::HostError(_))); + assert!(matches!(err, crate::SysError::JsonError(_))); } // The reply helpers publish over IPC, which requires a host. We assert