Skip to content

feat(sdk): hook handler API — astrid_sdk::hook module + #[hook] attribute#58

Merged
joshuajbouw merged 2 commits into
mainfrom
feat/hook-handler-api
Jun 16, 2026
Merged

feat(sdk): hook handler API — astrid_sdk::hook module + #[hook] attribute#58
joshuajbouw merged 2 commits into
mainfrom
feat/hook-handler-api

Conversation

@joshuajbouw

Copy link
Copy Markdown
Contributor

Plumbs the existing astrid:hook contract 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

  • Re-exports the canonical HookEventRequest / HookResult (from the generated contracts::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 the HookEventRequest, hands a typed &HookEvent to the method (fn(&self, &HookEvent) -> Result<Option<HookResult>, SysError>), auto-publishes any returned HookResult on 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.

#[hook("before_tool_call")]
fn before_tool_call(&self, ev: &hook::HookEvent) -> Result<Option<hook::HookResult>, SysError> {
    let call: ToolCall = ev.payload()?;
    if blocked(&call) { return Ok(Some(HookResult { skip: Some(true), data: None })); }
    Ok(None)
}

Verification

  • cargo clippy --workspace --all-features -- -D warnings clean; cargo fmt --check clean; sync-contracts-wit.sh --check in sync (bundle untouched).
  • 9 hook module tests + 3 macro-arm tests; example test-capsule builds a #[hook] method end-to-end (wasm32-wasip2).

Follow-up (separate): switch astrid-capsule-hook-bridge to publish the canonical type, and (once this releases) adopt the module ergonomics.

…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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread astrid-sdk/src/hook.rs
Comment on lines +120 to +126
/// # 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()))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).

Suggested change
/// # 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)
}

Comment thread astrid-sdk/src/hook.rs
Comment on lines +234 to +243
#[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(_)));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
#[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.
@joshuajbouw joshuajbouw merged commit cc25f1b into main Jun 16, 2026
8 checks passed
@joshuajbouw joshuajbouw deleted the feat/hook-handler-api branch June 16, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant