feat(sdk): hook handler API — astrid_sdk::hook module + #[hook] attribute#58
Conversation
…bute
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<T>, 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.
There was a problem hiding this comment.
Code Review
This pull request introduces support for lifecycle hooks in the Astrid SDK. It adds a new #[hook] macro attribute to subscribe to lifecycle events, implements the hook module in astrid-sdk to manage event deserialization and replies, and includes an example implementation in test-capsule. The feedback suggests improving error handling by mapping JSON deserialization errors to the dedicated SysError::JsonError variant instead of SysError::HostError, along with updating the corresponding unit test.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| /// # Errors | ||
| /// | ||
| /// Returns [`crate::SysError::HostError`] if the payload is not valid | ||
| /// JSON for `T`. | ||
| pub fn payload<T: ::serde::de::DeserializeOwned>(&self) -> Result<T, crate::SysError> { | ||
| ::serde_json::from_str(&self.payload).map_err(|e| crate::SysError::HostError(e.to_string())) | ||
| } |
There was a problem hiding this comment.
The payload method currently maps JSON deserialization errors to SysError::HostError. However, SysError has a dedicated JsonError variant (which implements From<serde_json::Error>). Using JsonError preserves the structured error information and is consistent with how JSON errors are handled elsewhere in the SDK (e.g., in kv::get_json).
| /// # Errors | |
| /// | |
| /// Returns [`crate::SysError::HostError`] if the payload is not valid | |
| /// JSON for `T`. | |
| pub fn payload<T: ::serde::de::DeserializeOwned>(&self) -> Result<T, crate::SysError> { | |
| ::serde_json::from_str(&self.payload).map_err(|e| crate::SysError::HostError(e.to_string())) | |
| } | |
| /// # Errors | |
| /// | |
| /// Returns crate::SysError::JsonError if the payload is not valid | |
| /// JSON for T. | |
| pub fn payload<T: ::serde::de::DeserializeOwned>(&self) -> Result<T, crate::SysError> { | |
| ::serde_json::from_str(&self.payload).map_err(crate::SysError::from) | |
| } |
| #[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::<ToolPayload>().unwrap_err(); | ||
| assert!(matches!(err, crate::SysError::HostError(_))); | ||
| } |
There was a problem hiding this comment.
Update the test to assert that a malformed payload returns SysError::JsonError instead of SysError::HostError to align with the improved error handling in the payload method.
| #[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::<ToolPayload>().unwrap_err(); | |
| assert!(matches!(err, crate::SysError::HostError(_))); | |
| } | |
| #[test] | |
| 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::<ToolPayload>().unwrap_err(); | |
| assert!(matches!(err, crate::SysError::JsonError(_))); | |
| } |
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.
Plumbs the existing
astrid:hookcontract into an ergonomic, STD-style capsule surface so a capsule can handle Astrid lifecycle hooks without hand-rolling subscribe/deserialize/match/reply.astrid_sdk::hook module
HookEventRequest/HookResult(from the generatedcontracts::hook).event_topic(name)/response_topic(name, corr_id);EVENT_PREFIX/RESPONSE_PREFIX.HookEvent:name(),correlation_id(),payload_str(),payload::<T: DeserializeOwned>(),reply(HookResult)(no-op for fire-and-forget hooks with no correlation id),skip(),respond_data().#[hook("name")] attribute
A specialized interceptor: action = the hook name (author declares
[subscribe] "hook.v1.event.<name>" = { handler = "<name>" }in Capsule.toml). The generated arm deserializes theHookEventRequest, hands a typed&HookEventto the method (fn(&self, &HookEvent) -> Result<Option<HookResult>, SysError>), auto-publishes any returnedHookResulton the scoped reply topic, and is fail-open throughout (malformed event / handler error / publish failure → log +continue; never wedges the capsule). Stateful capsules get the same KV load/persist lifecycle as the interceptor/tool arms.Verification
cargo clippy --workspace --all-features -- -D warningsclean;cargo fmt --checkclean;sync-contracts-wit.sh --checkin sync (bundle untouched).#[hook]method end-to-end (wasm32-wasip2).Follow-up (separate): switch
astrid-capsule-hook-bridgeto publish the canonical type, and (once this releases) adopt the module ergonomics.