0001-hooks-systemaccepted2026-04-11

Hooks System: Named, Typed, Pluggable Async Function Slots

hooksextensibilitymacros

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 last existed, 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:

  1. The library must never own a loop. Hooks are call points in user code, not lifecycle interceptors wired into an internal agent runtime.
  2. Hook storage must remain owner-local. User-defined slots live in app-owned #[hooks] hook sets, while framework-owned slots live in LutumHooksSet, 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 / fallback slots declare last: 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 or where clauses
  • 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:

  • last is always Some(_) 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 = None for the first registered hook
  • last = 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 last argument
  • 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>Set directly
  • Lutum owns LutumHooksSet for built-in runtime hooks such as resolve_usage_estimate
  • adapters own provider-specific hook sets such as OpenAiHooksSet and ClaudeHooksSet

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 last is 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 / singleton mode argument
  • Hooks still compose via the last chain 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::new continues to work with an empty LutumHooksSet
  • Adapter-local request shaping remains available without routing through Lutum