///|
/// One hook trait: interception points on the agent pipeline. 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.
// Hook errors and decisions
///|
/// Raised by `Hook::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.
pub(all) enum ToolHookDecision {
/// Execute the tool, possibly with a rewritten call.
Approve(call~ : @kernel.ToolCall)
/// Request suspension: keep the call pending so the host can resolve it
/// later. **Current runtime semantics: treated as a terminal reject** —
/// the run aborts with `HookRejected("pre_tool_hook defer not yet
/// supported: …")`. True suspend/resume is M5 work; do not rely on
/// `Defer` for suspension today.
Defer(reason~ : String)
/// Stop the run — the tool must not execute.
Reject(reason~ : String)
} derive(Debug)
///|
pub impl Show for ToolHookDecision with fn to_string(self : ToolHookDecision) -> String {
match self {
Approve(call~) => "Approve(\{call.call_id.to_string()})"
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 Hook trait
///|
/// Pipeline interception points. 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.
///
/// - `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. Approve (with
/// optional rewrite), Defer, or Reject. First non-Approve decision wins.
/// - `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`.
pub(open) trait Hook {
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) -> ToolHookDecision = _
fn on_post_event(Self, stage : HookStage) -> Unit = _
}
///|
/// Default `before_model`: pass messages through unchanged.
impl Hook with fn before_model(_self, messages : Array[@kernel.Message]) -> Array[
@kernel.Message,
] {
messages
}
///|
/// Default `before_tool`: approve the call unchanged.
impl Hook with fn before_tool(_self, call : @kernel.ToolCall) -> ToolHookDecision {
Approve(call~)
}
///|
/// Default `on_post_event`: no-op.
impl Hook with fn on_post_event(_self, _stage : HookStage) -> Unit {
()
}