119 lines
4.1 KiB
Go
119 lines
4.1 KiB
Go
// This file bridges the agent's event stream to observer-only hooks (US-011/012/
|
|
// 013, FR-1). Where PreToolUse/UserPromptSubmit/Stop are decision hooks wired at
|
|
// dedicated seams, SessionEnd/PreCompact/Notification only observe: the agent
|
|
// emits lifecycle events, HookNotifier maps each to a HookInput and fires the
|
|
// matching hooks via the Dispatcher, discarding the decision (an observer hook
|
|
// cannot block).
|
|
//
|
|
// It mirrors plugin.EventNotifier: created once per run, its Handle method is
|
|
// wired as an event-stream OnEvent callback and coexists with the plugin
|
|
// notifier (RunConfig.OnEvent already chains multiple observers). A nil
|
|
// *Dispatcher makes NewHookNotifier return nil, and every method is a no-op on a
|
|
// nil receiver, so callers can wire it unconditionally.
|
|
//
|
|
// Session id and project dir are fixed for a run, so they are captured at
|
|
// construction rather than read from each event (AgentEndEvent/CompactionEvent
|
|
// do not carry them).
|
|
package hooks
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
)
|
|
|
|
// HookNotifier forwards agent lifecycle events to observer-only hooks. Handle
|
|
// maps AgentEndEvent→SessionEnd and CompactionEvent→PreCompact; Notify emits a
|
|
// Notification event for out-of-band prompts (e.g. a trust confirmation). The
|
|
// merged decision is intentionally discarded — these events have no in-flight
|
|
// action to veto.
|
|
type HookNotifier struct {
|
|
d *Dispatcher
|
|
sessionID string
|
|
projectDir string
|
|
}
|
|
|
|
// NewHookNotifier returns a notifier over d, or nil when d is nil (no hooks) so
|
|
// the caller can skip the OnEvent wiring entirely. sessionID and projectDir
|
|
// populate every emitted HookInput.
|
|
func NewHookNotifier(d *Dispatcher, sessionID, projectDir string) *HookNotifier {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
return &HookNotifier{d: d, sessionID: sessionID, projectDir: projectDir}
|
|
}
|
|
|
|
// Handle maps an observed event to its observer hook and fires it. Events with
|
|
// no observer mapping (turn/message/tool events) are ignored. It is a no-op on a
|
|
// nil notifier, so it can be chained onto OnEvent unconditionally.
|
|
func (n *HookNotifier) Handle(ev agentcore.AgentEvent) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
switch e := ev.(type) {
|
|
case agentcore.AgentEndEvent:
|
|
n.dispatch("SessionEnd", HookInput{
|
|
EventType: "SessionEnd",
|
|
SessionID: n.sessionID,
|
|
ProjectDir: n.projectDir,
|
|
StopReason: sessionEndReason(e.Messages),
|
|
})
|
|
case agentcore.CompactionEvent:
|
|
n.dispatch("PreCompact", HookInput{
|
|
EventType: "PreCompact",
|
|
SessionID: n.sessionID,
|
|
ProjectDir: n.projectDir,
|
|
Trigger: compactionTrigger(e.Reason),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Notify fires the Notification event with a human-readable message. It is used
|
|
// for out-of-band prompts that are not part of the event stream, such as a trust
|
|
// confirmation for an untrusted-directory bash/write. A no-op on a nil notifier
|
|
// or an empty message.
|
|
func (n *HookNotifier) Notify(message string) {
|
|
if n == nil || message == "" {
|
|
return
|
|
}
|
|
n.dispatch("Notification", HookInput{
|
|
EventType: "Notification",
|
|
SessionID: n.sessionID,
|
|
ProjectDir: n.projectDir,
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
// dispatch fires the hooks for an observer event and discards the decision.
|
|
func (n *HookNotifier) dispatch(event string, input HookInput) {
|
|
n.d.Dispatch(context.Background(), event, "", input)
|
|
}
|
|
|
|
// sessionEndReason derives the SessionEnd reason from the run's terminal
|
|
// assistant message: "error"/"aborted" pass through, everything else (end_turn/
|
|
// tool_use/length or no assistant message) is a "natural" end.
|
|
func sessionEndReason(msgs []agentcore.AgentMessage) string {
|
|
for i := len(msgs) - 1; i >= 0; i-- {
|
|
if am, ok := msgs[i].(agentcore.AssistantMessage); ok {
|
|
switch am.StopReason {
|
|
case agentcore.StopReasonError:
|
|
return "error"
|
|
case agentcore.StopReasonAborted:
|
|
return "aborted"
|
|
default:
|
|
return "natural"
|
|
}
|
|
}
|
|
}
|
|
return "natural"
|
|
}
|
|
|
|
// compactionTrigger maps a CompactionEvent.Reason to the PreCompact trigger:
|
|
// "manual" stays manual; "threshold"/"overflow" (and anything else) are "auto".
|
|
func compactionTrigger(reason string) string {
|
|
if reason == "manual" {
|
|
return "manual"
|
|
}
|
|
return "auto"
|
|
}
|