Hooks System: Named, Typed, Pluggable Async Function Slots
Context
Lutum examples consistently duplicate cross-cutting concerns as plain functions:
pre/post validation gates (deterministic_hooks), output auditing (verification_gates),
and policy checks (architectural_constraints). These functions are not pluggable;
replacing them requires editing call sites.
The framework already exposes RequestExtensions and EventHandler for per-request
metadata and streaming event handling, but no mechanism existed for named, typed,
reusable decision points in user orchestration code.
The first shipped hook API used a separate #[def_hook(...)] definition macro and a
#[hooks] owner container that referenced generated slot types. That worked, but it
imposed costs that were not paying for themselves:
- slot definitions and their owner-local dispatch surface lived in different items
- proc-macro expansion relied on declarative-macro handshakes and same-name communication
hacks to move slot metadata from
#[def_hook]into#[hooks] - implementation-side behavior was partially inferred, especially around whether
lastexisted, which made signatures feel magical instead of explicit - external-slot composition across separate
#[hooks]owners did not justify the extra abstraction or maintenance burden
Several constraints remained non-negotiable:
- The library must never own a loop. Hooks are call points in user code, not lifecycle interceptors wired into an internal agent runtime.
- Hook storage must remain owner-local. User-defined slots live in app-owned
#[hooks]hook sets, while framework-owned slots live inLutumHooksSet,OpenAiHooksSet,ClaudeHooksSet, or derive-generated tool hook sets.
Decision
Make #[hooks] the only slot-definition surface.
#[hooks] is applied to a trait. Each async method annotated with
#[hook(always | fallback | singleton, ...)] defines one hook slot and its default
implementation. The trait remains the implementation trait. The macro also generates the
owner-local <TraitName>Set, the slot marker types, the hook traits, the dispatch methods, and
any builtin companion storage.
Example:
#[lutum::hooks]
trait AppHooks {
#[hook(fallback, chain = custom::PreferFirstSome)]
async fn choose_label(label: &str) -> Option<String> {
Some(format!("default:{label}"))
}
}
Single-slot implementation syntax becomes #[impl_hook(SlotType)]:
#[lutum::impl_hook(ChooseLabel)]
async fn prefer_cli(label: &str) -> Option<String> {
Some(format!("cli:{label}"))
}
#[impl_hook(SlotType)] is an exact-signature adapter. The implementation function must
match the generated hook trait method exactly:
- fold-style
always/fallbackslots declarelast: Option<Return>in the impl when and only when the generated trait has it - chain / aggregate companion slots do not rely on impl-side inference or injected arguments
- singleton slots never take
last
This is intentionally explicit. If a slot chains fold results, implementations write
last themselves every time. There is no macro inference layer.
One value can also implement several methods with
#[impl_hooks(AppHooksSet)] impl AppHooks for Data { ... }. That impl block uses ordinary
async fn methods and register_hooks(data) registers only the methods physically present in
the impl.
Builtin companion behaviors remain part of the slot definition:
chain = ...aggregate = ...finalize = ...output = ...
These companions are expanded directly by #[hooks] and rely only on builtin companion
traits (Chain, Aggregate, AggregateInto, Finalize, FinalizeInto). No general
cross-#[hooks] communication mechanism is added for them.
Additional rules:
- user-defined slot methods in
#[hooks]must provide a default body - user-defined slot methods in
#[hooks]do not support generics orwhereclauses - slot definitions must not declare
last #[def_hook]is removed entirely- the old implementation-side
#[hook(SlotType)]proc macro is removed and replaced by#[impl_hook(SlotType)]
Execution model
Fold-style slots pass last: Option<R> to registered implementations through the generated
trait. Slot definitions never write that parameter; implementations do when required.
For #[hook(always)] slots:
lastis alwaysSome(_)for registered hooks because the default already ran- the usual propagation pattern is
if let Some(Err(err)) = last { return Err(err); }
For #[hook(fallback)] slots:
last = Nonefor the first registered hooklast = Some(previous_result)for subsequent hooks
For #[hook(singleton)] slots:
- the default implementation runs when no hooks are registered
- one registered hook replaces the default and receives no
lastargument - more than one registered hook means the last registration wins and emits a warning
Ownership model
Every hook slot is hosted by an explicit local owner generated from one #[hooks] trait:
- user orchestration code defines app-owned
#[hooks]traits and calls the expanded<TraitName>Setdirectly LutumownsLutumHooksSetfor built-in runtime hooks such asresolve_usage_estimate- adapters own provider-specific hook sets such as
OpenAiHooksSetandClaudeHooksSet
Builtin hook
resolve_usage_estimate is declared with #[hook(singleton)] inside the LutumHooks trait and
is stored by LutumHooksSet.
Its default implementation reads a typed estimate from RequestExtensions and falls back
to zero.
Tracing
Each generated local hook dispatch method wraps execution in
tracing::info_span!("lutum_hook", name = "hook_name"), so hook invocations appear in
lutum-trace captures without extra user instrumentation.
Consequences
- Slot definitions and owner-local storage now live in one place: a single
#[hooks]trait - The macro implementation is substantially simpler: no same-name declarative-macro communication hacks are needed between slot definitions and owners
- Implementation signatures are explicit. If
lastis part of the contract, users write it. - Cross-cutting concerns such as validation, policy, and model selection can be defined once and attached to explicit owners instead of a shared registry
- User code remains the explicit call site. The library invokes only the documented builtin owner hooks automatically
- Default participation remains explicit at the definition site through the
always/fallback/singletonmode argument - Hooks still compose via the
lastchain parameter in registration order where chaining is intended, while singleton slots use a single effective override with last-registration-wins semantics - Stateful hooks remain first-class: any type implementing the generated hook trait can be registered, not just closures
- External slot reuse across unrelated
#[hooks]owners is no longer a goal of the API - This is a breaking change, but it improves user-facing locality and long-term maintenance
Lutum::newcontinues to work with an emptyLutumHooksSet- Adapter-local request shaping remains available without routing through
Lutum