mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
795ee362ea
Remove the legacy EventKind/Event envelope mapping and let agent event emission build pkg/events.Event values directly. Keep HookMeta as the shared hook metadata shape and preserve legacy observe string aliases by mapping them to runtime event kinds. Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/agent; make lint
360 lines
9.4 KiB
Go
360 lines
9.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
|
)
|
|
|
|
type hookRuntime struct {
|
|
initOnce sync.Once
|
|
mu sync.Mutex
|
|
initErr error
|
|
mounted []string
|
|
}
|
|
|
|
func (r *hookRuntime) setInitErr(err error) {
|
|
r.mu.Lock()
|
|
r.initErr = err
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
func (r *hookRuntime) getInitErr() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.initErr
|
|
}
|
|
|
|
func (r *hookRuntime) setMounted(names []string) {
|
|
r.mu.Lock()
|
|
r.mounted = append([]string(nil), names...)
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
func (r *hookRuntime) reset(al *AgentLoop) {
|
|
r.mu.Lock()
|
|
names := append([]string(nil), r.mounted...)
|
|
r.mounted = nil
|
|
r.initErr = nil
|
|
r.initOnce = sync.Once{}
|
|
r.mu.Unlock()
|
|
|
|
for _, name := range names {
|
|
al.UnmountHook(name)
|
|
}
|
|
}
|
|
|
|
// BuiltinHookFactory constructs an in-process hook from config.
|
|
type BuiltinHookFactory func(ctx context.Context, spec config.BuiltinHookConfig) (any, error)
|
|
|
|
var (
|
|
builtinHookRegistryMu sync.RWMutex
|
|
builtinHookRegistry = map[string]BuiltinHookFactory{}
|
|
)
|
|
|
|
// RegisterBuiltinHook registers a named in-process hook factory for config-driven mounting.
|
|
func RegisterBuiltinHook(name string, factory BuiltinHookFactory) error {
|
|
if name == "" {
|
|
return fmt.Errorf("builtin hook name is required")
|
|
}
|
|
if factory == nil {
|
|
return fmt.Errorf("builtin hook %q factory is nil", name)
|
|
}
|
|
|
|
builtinHookRegistryMu.Lock()
|
|
defer builtinHookRegistryMu.Unlock()
|
|
|
|
if _, exists := builtinHookRegistry[name]; exists {
|
|
return fmt.Errorf("builtin hook %q is already registered", name)
|
|
}
|
|
builtinHookRegistry[name] = factory
|
|
return nil
|
|
}
|
|
|
|
func unregisterBuiltinHook(name string) {
|
|
if name == "" {
|
|
return
|
|
}
|
|
builtinHookRegistryMu.Lock()
|
|
delete(builtinHookRegistry, name)
|
|
builtinHookRegistryMu.Unlock()
|
|
}
|
|
|
|
func lookupBuiltinHook(name string) (BuiltinHookFactory, bool) {
|
|
builtinHookRegistryMu.RLock()
|
|
defer builtinHookRegistryMu.RUnlock()
|
|
|
|
factory, ok := builtinHookRegistry[name]
|
|
return factory, ok
|
|
}
|
|
|
|
func configureHookManagerFromConfig(hm *HookManager, cfg *config.Config) {
|
|
if hm == nil || cfg == nil {
|
|
return
|
|
}
|
|
hm.ConfigureTimeouts(
|
|
hookTimeoutFromMS(cfg.Hooks.Defaults.ObserverTimeoutMS),
|
|
hookTimeoutFromMS(cfg.Hooks.Defaults.InterceptorTimeoutMS),
|
|
hookTimeoutFromMS(cfg.Hooks.Defaults.ApprovalTimeoutMS),
|
|
)
|
|
}
|
|
|
|
func hookTimeoutFromMS(ms int) time.Duration {
|
|
if ms <= 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(ms) * time.Millisecond
|
|
}
|
|
|
|
func (al *AgentLoop) ensureHooksInitialized(ctx context.Context) error {
|
|
if al == nil || al.cfg == nil || al.hooks == nil {
|
|
return nil
|
|
}
|
|
|
|
al.hookRuntime.initOnce.Do(func() {
|
|
al.hookRuntime.setInitErr(al.loadConfiguredHooks(ctx))
|
|
})
|
|
|
|
return al.hookRuntime.getInitErr()
|
|
}
|
|
|
|
func (al *AgentLoop) loadConfiguredHooks(ctx context.Context) (err error) {
|
|
if al == nil || al.cfg == nil || !al.cfg.Hooks.Enabled {
|
|
return nil
|
|
}
|
|
|
|
mounted := make([]string, 0)
|
|
defer func() {
|
|
if err != nil {
|
|
for _, name := range mounted {
|
|
al.UnmountHook(name)
|
|
}
|
|
return
|
|
}
|
|
al.hookRuntime.setMounted(mounted)
|
|
}()
|
|
|
|
builtinNames := enabledBuiltinHookNames(al.cfg.Hooks.Builtins)
|
|
for _, name := range builtinNames {
|
|
spec := al.cfg.Hooks.Builtins[name]
|
|
factory, ok := lookupBuiltinHook(name)
|
|
if !ok {
|
|
return fmt.Errorf("builtin hook %q is not registered", name)
|
|
}
|
|
|
|
hook, factoryErr := factory(ctx, spec)
|
|
if factoryErr != nil {
|
|
return fmt.Errorf("build builtin hook %q: %w", name, factoryErr)
|
|
}
|
|
if err := al.MountHook(HookRegistration{
|
|
Name: name,
|
|
Priority: spec.Priority,
|
|
Source: HookSourceInProcess,
|
|
Hook: hook,
|
|
}); err != nil {
|
|
return fmt.Errorf("mount builtin hook %q: %w", name, err)
|
|
}
|
|
mounted = append(mounted, name)
|
|
}
|
|
|
|
processNames := enabledProcessHookNames(al.cfg.Hooks.Processes)
|
|
for _, name := range processNames {
|
|
spec := al.cfg.Hooks.Processes[name]
|
|
opts, buildErr := processHookOptionsFromConfig(spec)
|
|
if buildErr != nil {
|
|
return fmt.Errorf("configure process hook %q: %w", name, buildErr)
|
|
}
|
|
|
|
processHook, buildErr := NewProcessHook(ctx, name, opts)
|
|
if buildErr != nil {
|
|
return fmt.Errorf("start process hook %q: %w", name, buildErr)
|
|
}
|
|
if err := al.MountHook(HookRegistration{
|
|
Name: name,
|
|
Priority: spec.Priority,
|
|
Source: HookSourceProcess,
|
|
Hook: processHook,
|
|
}); err != nil {
|
|
_ = processHook.Close()
|
|
return fmt.Errorf("mount process hook %q: %w", name, err)
|
|
}
|
|
mounted = append(mounted, name)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func enabledBuiltinHookNames(specs map[string]config.BuiltinHookConfig) []string {
|
|
if len(specs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
names := make([]string, 0, len(specs))
|
|
for name, spec := range specs {
|
|
if spec.Enabled {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func enabledProcessHookNames(specs map[string]config.ProcessHookConfig) []string {
|
|
if len(specs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
names := make([]string, 0, len(specs))
|
|
for name, spec := range specs {
|
|
if spec.Enabled {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func processHookOptionsFromConfig(spec config.ProcessHookConfig) (ProcessHookOptions, error) {
|
|
transport := spec.Transport
|
|
if transport == "" {
|
|
transport = "stdio"
|
|
}
|
|
if transport != "stdio" {
|
|
return ProcessHookOptions{}, fmt.Errorf("unsupported transport %q", transport)
|
|
}
|
|
if len(spec.Command) == 0 {
|
|
return ProcessHookOptions{}, fmt.Errorf("command is required")
|
|
}
|
|
|
|
opts := ProcessHookOptions{
|
|
Command: append([]string(nil), spec.Command...),
|
|
Dir: spec.Dir,
|
|
Env: processHookEnvFromMap(spec.Env),
|
|
}
|
|
|
|
observeKinds, observeEnabled, err := processHookObserveKindsFromConfig(spec.Observe)
|
|
if err != nil {
|
|
return ProcessHookOptions{}, err
|
|
}
|
|
opts.Observe = observeEnabled
|
|
opts.ObserveKinds = observeKinds
|
|
|
|
for _, intercept := range spec.Intercept {
|
|
switch intercept {
|
|
case "before_llm", "after_llm":
|
|
opts.InterceptLLM = true
|
|
case "before_tool", "after_tool":
|
|
opts.InterceptTool = true
|
|
case "approve_tool":
|
|
opts.ApproveTool = true
|
|
case "":
|
|
continue
|
|
default:
|
|
return ProcessHookOptions{}, fmt.Errorf("unsupported intercept %q", intercept)
|
|
}
|
|
}
|
|
|
|
if !opts.Observe && !opts.InterceptLLM && !opts.InterceptTool && !opts.ApproveTool {
|
|
return ProcessHookOptions{}, fmt.Errorf("no hook modes enabled")
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
func processHookEnvFromMap(envMap map[string]string) []string {
|
|
if len(envMap) == 0 {
|
|
return nil
|
|
}
|
|
|
|
keys := make([]string, 0, len(envMap))
|
|
for key := range envMap {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
env := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
env = append(env, key+"="+envMap[key])
|
|
}
|
|
return env
|
|
}
|
|
|
|
func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error) {
|
|
if len(observe) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
|
|
validKinds := validHookEventKinds()
|
|
normalized := make([]string, 0, len(observe))
|
|
for _, kind := range observe {
|
|
switch kind {
|
|
case "", "*", "all":
|
|
return nil, true, nil
|
|
default:
|
|
normalizedKind, ok := validKinds[kind]
|
|
if !ok {
|
|
return nil, false, fmt.Errorf("unsupported observe event %q", kind)
|
|
}
|
|
normalized = append(normalized, normalizedKind)
|
|
}
|
|
}
|
|
|
|
if len(normalized) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
return normalized, true, nil
|
|
}
|
|
|
|
func validHookEventKinds() map[string]string {
|
|
runtimeKinds := []runtimeevents.Kind{
|
|
runtimeevents.KindAgentTurnStart,
|
|
runtimeevents.KindAgentTurnEnd,
|
|
runtimeevents.KindAgentLLMRequest,
|
|
runtimeevents.KindAgentLLMDelta,
|
|
runtimeevents.KindAgentLLMResponse,
|
|
runtimeevents.KindAgentLLMRetry,
|
|
runtimeevents.KindAgentContextCompress,
|
|
runtimeevents.KindAgentSessionSummarize,
|
|
runtimeevents.KindAgentToolExecStart,
|
|
runtimeevents.KindAgentToolExecEnd,
|
|
runtimeevents.KindAgentToolExecSkipped,
|
|
runtimeevents.KindAgentSteeringInjected,
|
|
runtimeevents.KindAgentFollowUpQueued,
|
|
runtimeevents.KindAgentInterruptReceived,
|
|
runtimeevents.KindAgentSubTurnSpawn,
|
|
runtimeevents.KindAgentSubTurnEnd,
|
|
runtimeevents.KindAgentSubTurnResultDelivered,
|
|
runtimeevents.KindAgentSubTurnOrphan,
|
|
runtimeevents.KindAgentError,
|
|
}
|
|
kinds := make(map[string]string, len(runtimeKinds)*2)
|
|
for _, kind := range runtimeKinds {
|
|
kinds[kind.String()] = kind.String()
|
|
}
|
|
kinds["turn_start"] = runtimeevents.KindAgentTurnStart.String()
|
|
kinds["turn_end"] = runtimeevents.KindAgentTurnEnd.String()
|
|
kinds["llm_request"] = runtimeevents.KindAgentLLMRequest.String()
|
|
kinds["llm_delta"] = runtimeevents.KindAgentLLMDelta.String()
|
|
kinds["llm_response"] = runtimeevents.KindAgentLLMResponse.String()
|
|
kinds["llm_retry"] = runtimeevents.KindAgentLLMRetry.String()
|
|
kinds["context_compress"] = runtimeevents.KindAgentContextCompress.String()
|
|
kinds["session_summarize"] = runtimeevents.KindAgentSessionSummarize.String()
|
|
kinds["tool_exec_start"] = runtimeevents.KindAgentToolExecStart.String()
|
|
kinds["tool_exec_end"] = runtimeevents.KindAgentToolExecEnd.String()
|
|
kinds["tool_exec_skipped"] = runtimeevents.KindAgentToolExecSkipped.String()
|
|
kinds["steering_injected"] = runtimeevents.KindAgentSteeringInjected.String()
|
|
kinds["follow_up_queued"] = runtimeevents.KindAgentFollowUpQueued.String()
|
|
kinds["interrupt_received"] = runtimeevents.KindAgentInterruptReceived.String()
|
|
kinds["subturn_spawn"] = runtimeevents.KindAgentSubTurnSpawn.String()
|
|
kinds["subturn_end"] = runtimeevents.KindAgentSubTurnEnd.String()
|
|
kinds["subturn_result_delivered"] = runtimeevents.KindAgentSubTurnResultDelivered.String()
|
|
kinds["subturn_orphan"] = runtimeevents.KindAgentSubTurnOrphan.String()
|
|
kinds["error"] = runtimeevents.KindAgentError.String()
|
|
return kinds
|
|
}
|