first commit
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user