///|
/// One hook trait: interception points across nearly the whole agent
/// lifecycle — turn begin/end, before/after the model, before/after tools.
/// Each method is a distinct point with its own precise contract (M3.6
/// lesson: the semantics of "can abort", "can rewrite" and "read-only" must
/// not be squeezed into one signature). Every method has a default
/// implementation, so implementors override only the points they care
/// about, and adding a new interception point is non-breaking: one
/// defaulted method here plus one call site in the pump — manifest,
/// aggregation and config signatures stay untouched. Hooks run in
/// registration order at every point.
///
/// Hooks run synchronously *in the async flow*: async spreads — IO in a
/// method body propagates through async — but the pipeline dispatches each
/// hook sequentially, awaiting one before the next, and never spawns
/// threads or workers, so registration order is preserved everywhere.
// PipelineHook errors and decisions
///|
/// Raised by `PipelineHook::before_model` to stop the run before the model
/// is called.
pub(all) suberror HookAbort {
Aborted(reason~ : String)
}
///|
pub impl Show for HookAbort with fn to_string(self : HookAbort) -> String {
match self {
Aborted(reason~) => "HookAbort(\{reason})"
}
}
///|
/// The hook's decision for a pending tool call.
///
/// Chain semantics: every registered hook is evaluated (no
/// short-circuit). Precedence: a raise aborts the run; `Defer`
/// terminal-rejects until M5; `ApproveAfterConsent` beats `Reject`;
/// `Reject` skips the call and feeds the joined reasons back to the model
/// as a `NotExecuted(RejectedByHook)` tool result; otherwise the call
/// executes with the chained rewrite.
pub(all) enum ToolDecision {
/// Execute the tool, possibly with a rewritten call.
Approve(call~ : @kernel.ToolCall)
/// Execute because the user explicitly consented (permission approval
/// or remembered session approval). Behaves like `Approve` but beats
/// any `Reject` in the chain; only raising outranks it.
/// `consent_scope` names what the user agreed to (e.g. `bash:ls`).
ApproveAfterConsent(call~ : @kernel.ToolCall, consent_scope~ : String)
/// Request suspension: keep the call pending so the host can resolve it
/// later. **Current runtime semantics: treated as a terminal reject** —
/// the run aborts, surfaced to the caller as `AgentError::PipelineAborted`
/// ("pre_tool_hook defer not yet supported: …"). True suspend/resume is
/// M5 work; do not rely on `Defer` for suspension today.
Defer(reason~ : String)
/// Skip this call: resolved as `NotExecuted(RejectedByHook)` whose
/// `reason` is fed back to the model; the run continues on the next
/// model step. Raising stays terminal; another hook's
/// `ApproveAfterConsent` overrides this.
Reject(reason~ : String)
} derive(Debug)
///|
pub impl Show for ToolDecision with fn to_string(self : ToolDecision) -> String {
match self {
Approve(call~) => "Approve(\{call.call_id.to_string()})"
ApproveAfterConsent(call~, consent_scope~) =>
"ApproveAfterConsent(\{call.call_id.to_string()}, scope=\{consent_scope})"
Defer(reason~) => "Defer(\{reason})"
Reject(reason~) => "Reject(\{reason})"
}
}
// Post-event stage
///|
/// Describes what just completed, delivered after the effect is committed.
pub(all) enum HookStage {
/// Model call completed successfully.
ModelCompleted(completion~ : @kernel.Completion)
/// Tool call completed (success, error, NotExecuted).
ToolCompleted(call~ : @kernel.ToolCall, outcome~ : @kernel.ToolOutcome)
/// Tool call failed at the adapter level.
ToolFailed(call~ : @kernel.ToolCall, reason~ : String)
/// Model call failed at the adapter level.
ModelFailed(failure~ : @kernel.ModelFailure)
} derive(Debug)
///|
pub impl Show for HookStage with fn to_string(self : HookStage) -> String {
match self {
ModelCompleted(..) => "ModelCompleted"
ToolCompleted(call~, ..) => "ToolCompleted(\{call.call_id.to_string()})"
ToolFailed(call~, ..) => "ToolFailed(\{call.call_id.to_string()})"
ModelFailed(..) => "ModelFailed"
}
}
// The PipelineHook trait
///|
/// Pipeline interception points covering nearly the whole agent lifecycle:
/// turn begin/end, before/after the model, before/after tools.
/// Registration is unified: an extension puts `self` (or any implementor)
/// into the manifest's single `hooks` array and overrides the methods for
/// the points it cares about.
///
/// - `on_turn_begin`: async turn-start slot. Dispatched once per
/// `run_single_turn`, after the `TurnStarted` observer projection and
/// before the pump's first `before_model`. Symmetric with `on_turn_end`:
/// pre-turn failures (agent-shutdown guard, lifecycle `on_start` raises)
/// dispatch nothing — the turn never began. A raise is a secondary
/// failure — reported to observers, never failing the turn — and later
/// hooks still run; cancellation is not a defect.
/// - `before_model`: invoked before the model is called. Can rewrite
/// messages or raise `HookAbort`. Multiple hooks form a chained rewrite
/// pipeline — each hook sees the previous hook's output.
/// - `before_tool`: invoked before every tool execution. Every hook is
/// evaluated; merge precedence on `ToolDecision` (consent beats
/// Reject; Defer still aborts the run until M5).
/// - `on_post_event`: invoked after every effect completes. Read-only and
/// non-raising — all registered hooks run in registration order; a
/// handler cannot fail in a typed way, and a contract violation aborts
/// loudly by design. For interception or abortion use `before_model` /
/// `before_tool`.
/// - `on_post_event_at`: scoped variant of `on_post_event`, additionally
/// carrying the `EventScope` attribution of the run that committed the
/// effect. The core dispatches ONLY this variant; its default delegates
/// to `on_post_event`, so pre-scope hooks keep working unchanged.
/// - `on_turn_end`: async turn-end slot, no payload. The terminal outcome
/// already lives on the Observer channel — `TurnCompleted` /
/// `TurnFailed(String)` — and an extension typically implements Observer
/// and PipelineHook as two views over one shared struct, so a hook that
/// needs the outcome reads its own observer-side state instead of a
/// second payload type. Dispatched
/// once per `run_turn`, after the terminal observer projection
/// (`TurnCompleted` / `TurnFailed`) and before the call returns — on both
/// the completed and the failed path. This is the slot for side effects
/// that must finish by turn end, such as committing buffered writes to an
/// external system. Implementations must carry their own timeout budget
/// and may not block the turn's return indefinitely. Division of labor
/// with `Observer::on_event_at(TurnCompleted)`: the observer slot is
/// sync, read-only and fires first; `on_turn_end` may await. A raise from
/// `on_turn_end` is a secondary failure — reported to observers, never
/// replacing the turn's primary outcome — and cancellation is not a
/// defect.
///
/// Async spread: the slots are `async`, so IO in an implementation body
/// propagates through async. The pipeline dispatches hooks sequentially —
/// each hook is awaited before the next runs — and never opens a thread or
/// worker, so registration order is preserved at every point.
pub(open) trait PipelineHook {
/// `before_model` is `async` so hooks whose rewrite needs IO can await.
/// Synchronous implementations write a plain `fn` body — a sync body
/// satisfies an `async` trait slot under MoonBit's colorless-coroutine
/// model. Raising `HookAbort` stops the run before the model is called.
async fn before_model(Self, messages : Array[@kernel.Message]) -> Array[
@kernel.Message,
] raise HookAbort = _
/// `before_tool` is `async` so hooks that need host interaction (e.g. an
/// approval prompt over `UiPort::request`) can suspend. Hooks that decide
/// synchronously implement it as a plain `fn` — a sync `fn` satisfies an
/// `async` trait slot under MoonBit's colorless-coroutine model. Raising
/// from `before_tool` aborts the run as a terminal `HookRejected`; a
/// cancellation error stays classified as cancellation, not a hook defect.
async fn before_tool(Self, call : @kernel.ToolCall) -> ToolDecision = _
/// `on_post_event` is `async` so read-only observers of effects (e.g. a
/// telemetry sink) can await their IO. `noraise` keeps the read-only,
/// non-raising contract: a handler cannot fail in a typed way. Sync bodies
/// keep working unchanged.
async fn on_post_event(Self, stage : HookStage) -> Unit noraise = _
async fn on_post_event_at(Self, scope : @types.EventScope?, stage : HookStage) -> Unit noraise = _
async fn on_turn_begin(Self) -> Unit = _
/// `on_turn_end` is `async` so hooks with turn-end side effects that must
/// complete before the turn returns (e.g. committing buffered writes to an
/// external system) can await. The terminal outcome is not a payload: read
/// it from the Observer channel (`TurnCompleted` / `TurnFailed`), which an
/// extension shares state with as a second view over the same struct.
/// Synchronous implementations write a plain `fn` body — a sync `fn`
/// satisfies an `async` trait slot under MoonBit's colorless-coroutine
/// model.
async fn on_turn_end(Self) -> Unit = _
}
///|
/// Default `before_model`: pass messages through unchanged.
impl PipelineHook with fn before_model(_self, messages : Array[@kernel.Message]) -> Array[
@kernel.Message,
] {
messages
}
///|
/// Default `before_tool`: approve the call unchanged.
impl PipelineHook with fn before_tool(_self, call : @kernel.ToolCall) -> ToolDecision {
Approve(call~)
}
///|
/// Default `on_post_event`: no-op.
impl PipelineHook with fn on_post_event(_self, _stage : HookStage) -> Unit {
()
}
///|
/// Default `on_post_event_at`: drop the scope and delegate to
/// `on_post_event`. Override this instead when run attribution matters.
impl PipelineHook with fn on_post_event_at(
self,
_scope : @types.EventScope?,
stage : HookStage,
) -> Unit {
self.on_post_event(stage)
}
///|
/// Default `on_turn_begin`: no-op.
impl PipelineHook with fn on_turn_begin(_self) -> Unit {
()
}
///|
/// Default `on_turn_end`: no-op. Sync body satisfies the async slot.
impl PipelineHook with fn on_turn_end(_self) -> Unit {
()
}