Files
2026-08-14 23:41:57 +08:00

224 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This file provides the single cli-layer assembly helper that composes hook
// dispatch into a runtime.RunConfig. It lives in the cli layer (not runtime) to
// avoid a runtime→hooks→runtime import cycle: runtime stays hook-agnostic and
// only exposes the generic seams (BeforeToolCall/AfterToolCall/ShouldStopAfterTurn),
// while this helper knows about both the resolved Config.Hooks and the seams.
//
// #419 establishes the skeleton: InstallHooks builds a Dispatcher (or short-
// circuits to nil when no hooks are configured, FR-18) and offers generic
// decorator combinators for the seams. The concrete per-event wiring is filled
// in by later issues (#420#424); this file deliberately wires no event yet.
package run
import (
"context"
"encoding/json"
"io"
"os"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/runtime"
)
// HookDeps carries the run-scoped context a Dispatcher needs: the session id and
// project directory that populate each HookInput / the hook process environment,
// and the writer that receives isolation warnings. WarnLog may be nil (defaults
// to os.Stderr), matching the plugin.EventNotifier convention.
type HookDeps struct {
SessionID string
ProjectDir string
WarnLog io.Writer
}
// InstallHooks builds the Dispatcher for a run from the resolved hook set and
// wires the tool-execution hook points into cfg. It short-circuits when no hooks
// are configured: NewDispatcher returns nil for an empty set, so the hot path
// pays nothing (FR-18) and nothing is wrapped. The returned dispatcher is used
// by later per-event wiring (#421#424).
//
// PreToolUse is CHAINED onto the existing BeforeToolCall seam (occupied by the
// trust gate) rather than replacing it: trust runs first and stays authoritative
// (a trust block short-circuits before the user hook runs). PostToolUse is
// chained onto AfterToolCall as a last-writer so it can append feedback to an
// already-executed tool's result without undoing it.
func InstallHooks(cfg *runtime.RunConfig, set hooks.HookSet, deps HookDeps) *hooks.Dispatcher {
warn := deps.WarnLog
if warn == nil {
warn = os.Stderr
}
d := hooks.NewDispatcher(set, deps.ProjectDir, warn)
if d == nil {
return nil
}
InstallSeams(cfg, d, deps)
return d
}
// BuildDispatcher builds the run's Dispatcher from the resolved hook set without
// touching a RunConfig. It is the entry point for the multi-turn drivers (REPL /
// TUI) that resolve hooks and fire SessionStart once per session, then install
// the per-turn seams (InstallSeams) on each turn's freshly-built RunConfig. It
// returns nil for an empty set (FR-18), so callers gate all hook work on non-nil.
func BuildDispatcher(set hooks.HookSet, deps HookDeps) *hooks.Dispatcher {
warn := deps.WarnLog
if warn == nil {
warn = os.Stderr
}
return hooks.NewDispatcher(set, deps.ProjectDir, warn)
}
// InstallSeams wires the tool-execution and Stop hook points onto cfg from an
// already-built dispatcher. It is the shared body of InstallHooks and the
// per-turn install path for multi-turn drivers. A nil dispatcher is a no-op, so
// callers can invoke it unconditionally.
//
// PreToolUse is CHAINED onto the existing BeforeToolCall seam (occupied by the
// trust gate) rather than replacing it: trust runs first and stays authoritative
// (a trust block short-circuits before the user hook runs). PostToolUse is
// chained onto AfterToolCall as a last-writer so it can append feedback to an
// already-executed tool's result without undoing it. The Stop hook is chained
// onto the loop's natural-end seam (FR-10), bounded by the decorator's
// consecutive-block counter (FR-12).
func InstallSeams(cfg *runtime.RunConfig, d *hooks.Dispatcher, deps HookDeps) {
if d == nil {
return
}
tec := &cfg.Batch.ToolExecutorConfig
tec.BeforeToolCall = chainBeforeToolCall(tec.BeforeToolCall, preToolCallHook(d, deps))
tec.AfterToolCall = chainAfterToolCall(tec.AfterToolCall, postToolCallHook(d, deps))
installStopHook(cfg, d, deps, "Stop")
}
// preToolCallHook adapts the dispatcher's PreToolUse event to the BeforeToolCall
// seam. It dispatches with the tool name and raw arguments; a block becomes a
// blocking decision whose reason is surfaced as the tool's error result, and an
// updatedInput becomes an argument rewrite (re-validated by the executor).
func preToolCallHook(d *hooks.Dispatcher, deps HookDeps) agentcore.BeforeToolCallFunc {
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
dec := d.Dispatch(ctx, hooks.EventPreToolUse, call.Name, hooks.HookInput{
EventType: hooks.EventPreToolUse,
SessionID: deps.SessionID,
ProjectDir: deps.ProjectDir,
ToolName: call.Name,
ToolInput: call.Arguments,
})
if dec.Block {
content := agentcore.ContentList{agentcore.NewTextContent(hookReason(dec.Reason, call.Name))}
return &agentcore.BeforeToolCallDecision{Block: true, Content: &content}
}
if len(dec.UpdatedInput) > 0 {
return &agentcore.BeforeToolCallDecision{UpdatedInput: dec.UpdatedInput}
}
return nil
}
}
// postToolCallHook adapts the dispatcher's PostToolUse event to the AfterToolCall
// seam. It dispatches with the tool name, raw arguments, and the tool's response,
// then appends any reason/additionalContext to the result content as a new text
// block (the executed tool is never undone). A block on Post is treated as
// feedback only: it cannot retract an already-run tool, so we surface the reason.
func postToolCallHook(d *hooks.Dispatcher, deps HookDeps) agentcore.AfterToolCallFunc {
return func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
var resp json.RawMessage
if b, err := json.Marshal(result.Content); err == nil {
resp = b
}
dec := d.Dispatch(ctx, "PostToolUse", call.Name, hooks.HookInput{
EventType: "PostToolUse",
SessionID: deps.SessionID,
ProjectDir: deps.ProjectDir,
ToolName: call.Name,
ToolInput: call.Arguments,
ToolResponse: resp,
})
feedback := joinHookText(dec.Reason, dec.AdditionalContext)
if feedback == "" {
return nil
}
content := append(agentcore.ContentList{}, result.Content...)
content = append(content, agentcore.NewTextContent(feedback))
return &agentcore.AfterToolCallResult{Content: &content}
}
}
// hookReason returns the block reason, falling back to a generic message keyed on
// the tool name when the hook gave no reason.
func hookReason(reason, toolName string) string {
if reason != "" {
return reason
}
return "tool " + toolName + " blocked by PreToolUse hook"
}
// joinHookText joins two hook text fields with a newline, dropping empties.
func joinHookText(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
default:
return a + "\n" + b
}
}
// chainBeforeToolCall composes two BeforeToolCall seams into one that runs prev
// first, then next. A blocking decision from prev short-circuits (next does not
// run), so an earlier gate (e.g. trust) is authoritative over a later hook. A
// nil operand is treated as identity, so composing onto an unset seam returns
// the other unchanged.
func chainBeforeToolCall(prev, next agentcore.BeforeToolCallFunc) agentcore.BeforeToolCallFunc {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
if dec := prev(ctx, call); dec != nil && dec.Block {
return dec
}
return next(ctx, call)
}
}
// chainAfterToolCall composes two AfterToolCall seams into one that runs prev
// first, then next. next's non-nil result wins (last writer), so a hook layered
// after an existing seam can override it; when next returns nil, prev's result
// is preserved. A nil operand is identity.
func chainAfterToolCall(prev, next agentcore.AfterToolCallFunc) agentcore.AfterToolCallFunc {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
prevRes := prev(ctx, call, result, isError)
if nextRes := next(ctx, call, result, isError); nextRes != nil {
return nextRes
}
return prevRes
}
}
// chainShouldStop composes two ShouldStopAfterTurn seams with OR semantics: the
// run stops after a turn if either predicate says so. prev runs first and
// short-circuits when true. A nil operand is identity.
func chainShouldStop(prev, next func(context.Context, *agentcore.AgentContext) bool) func(context.Context, *agentcore.AgentContext) bool {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ctx context.Context, agentCtx *agentcore.AgentContext) bool {
if prev(ctx, agentCtx) {
return true
}
return next(ctx, agentCtx)
}
}