fix(hooks): isolate void-hook events per handler
This commit is contained in:
parent
3132756623
commit
d343d5e5f0
2 changed files with 235 additions and 8 deletions
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HookHandler is the callback signature for all hooks.
|
// HookHandler is the callback signature for all hooks.
|
||||||
|
|
@ -124,8 +125,146 @@ func (r *HookRegistry) OnSessionEnd(name string, priority int, handler HookHandl
|
||||||
|
|
||||||
// Trigger methods — void hooks
|
// Trigger methods — void hooks
|
||||||
|
|
||||||
|
func cloneMapStringString(src map[string]string) map[string]string {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMapStringAny(src map[string]any) map[string]any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make(map[string]any, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = cloneAny(v)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAny(v any) any {
|
||||||
|
switch tv := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return cloneMapStringAny(tv)
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(tv))
|
||||||
|
for i := range tv {
|
||||||
|
out[i] = cloneAny(tv[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneToolCall(tc providers.ToolCall) providers.ToolCall {
|
||||||
|
out := tc
|
||||||
|
out.Arguments = cloneMapStringAny(tc.Arguments)
|
||||||
|
if tc.Function != nil {
|
||||||
|
f := *tc.Function
|
||||||
|
out.Function = &f
|
||||||
|
}
|
||||||
|
if tc.ExtraContent != nil {
|
||||||
|
ec := *tc.ExtraContent
|
||||||
|
if tc.ExtraContent.Google != nil {
|
||||||
|
g := *tc.ExtraContent.Google
|
||||||
|
ec.Google = &g
|
||||||
|
}
|
||||||
|
out.ExtraContent = &ec
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMessage(msg providers.Message) providers.Message {
|
||||||
|
out := msg
|
||||||
|
if msg.ToolCalls != nil {
|
||||||
|
out.ToolCalls = make([]providers.ToolCall, len(msg.ToolCalls))
|
||||||
|
for i := range msg.ToolCalls {
|
||||||
|
out.ToolCalls[i] = cloneToolCall(msg.ToolCalls[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg.SystemParts != nil {
|
||||||
|
out.SystemParts = make([]providers.ContentBlock, len(msg.SystemParts))
|
||||||
|
for i := range msg.SystemParts {
|
||||||
|
part := msg.SystemParts[i]
|
||||||
|
if part.CacheControl != nil {
|
||||||
|
cc := *part.CacheControl
|
||||||
|
part.CacheControl = &cc
|
||||||
|
}
|
||||||
|
out.SystemParts[i] = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneToolDefinition(td providers.ToolDefinition) providers.ToolDefinition {
|
||||||
|
out := td
|
||||||
|
out.Function = td.Function
|
||||||
|
out.Function.Parameters = cloneMapStringAny(td.Function.Parameters)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneVoidEvent[T any](event *T) *T {
|
||||||
|
if event == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e := any(event).(type) {
|
||||||
|
case *MessageReceivedEvent:
|
||||||
|
c := *e
|
||||||
|
if e.Media != nil {
|
||||||
|
c.Media = append([]string(nil), e.Media...)
|
||||||
|
}
|
||||||
|
c.Metadata = cloneMapStringString(e.Metadata)
|
||||||
|
return any(&c).(*T)
|
||||||
|
case *AfterToolCallEvent:
|
||||||
|
c := *e
|
||||||
|
c.Args = cloneMapStringAny(e.Args)
|
||||||
|
if e.Result != nil {
|
||||||
|
r := *e.Result
|
||||||
|
c.Result = &r
|
||||||
|
}
|
||||||
|
return any(&c).(*T)
|
||||||
|
case *LLMInputEvent:
|
||||||
|
c := *e
|
||||||
|
if e.Messages != nil {
|
||||||
|
c.Messages = make([]providers.Message, len(e.Messages))
|
||||||
|
for i := range e.Messages {
|
||||||
|
c.Messages[i] = cloneMessage(e.Messages[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Tools != nil {
|
||||||
|
c.Tools = make([]providers.ToolDefinition, len(e.Tools))
|
||||||
|
for i := range e.Tools {
|
||||||
|
c.Tools[i] = cloneToolDefinition(e.Tools[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any(&c).(*T)
|
||||||
|
case *LLMOutputEvent:
|
||||||
|
c := *e
|
||||||
|
if e.ToolCalls != nil {
|
||||||
|
c.ToolCalls = make([]providers.ToolCall, len(e.ToolCalls))
|
||||||
|
for i := range e.ToolCalls {
|
||||||
|
c.ToolCalls[i] = cloneToolCall(e.ToolCalls[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any(&c).(*T)
|
||||||
|
case *SessionEvent:
|
||||||
|
c := *e
|
||||||
|
return any(&c).(*T)
|
||||||
|
default:
|
||||||
|
c := *event
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// triggerVoid runs all handlers concurrently and waits for completion.
|
// triggerVoid runs all handlers concurrently and waits for completion.
|
||||||
// Handlers MUST NOT mutate the event — it is shared across goroutines.
|
// Each handler receives a cloned event to avoid shared-state mutation races.
|
||||||
// Errors are logged but do not propagate to the caller.
|
// Errors are logged but do not propagate to the caller.
|
||||||
func triggerVoid[T any](ctx context.Context, hooks []HookRegistration[T], event *T, hookName string) {
|
func triggerVoid[T any](ctx context.Context, hooks []HookRegistration[T], event *T, hookName string) {
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
|
|
@ -136,6 +275,7 @@ func triggerVoid[T any](ctx context.Context, hooks []HookRegistration[T], event
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(reg HookRegistration[T]) {
|
go func(reg HookRegistration[T]) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
eventCopy := cloneVoidEvent(event)
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
logger.ErrorCF("hooks", "Hook panic",
|
logger.ErrorCF("hooks", "Hook panic",
|
||||||
|
|
@ -146,7 +286,7 @@ func triggerVoid[T any](ctx context.Context, hooks []HookRegistration[T], event
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if err := reg.Handler(ctx, event); err != nil {
|
if err := reg.Handler(ctx, eventCopy); err != nil {
|
||||||
logger.WarnCF("hooks", "Hook error",
|
logger.WarnCF("hooks", "Hook error",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"hook": hookName,
|
"hook": hookName,
|
||||||
|
|
@ -204,7 +344,7 @@ func triggerModifying[T any](
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerMessageReceived fires all message_received handlers concurrently.
|
// TriggerMessageReceived fires all message_received handlers concurrently.
|
||||||
// Handlers must not mutate the event.
|
// Handler mutations are isolated per hook invocation and are not propagated.
|
||||||
func (r *HookRegistry) TriggerMessageReceived(ctx context.Context, event *MessageReceivedEvent) {
|
func (r *HookRegistry) TriggerMessageReceived(ctx context.Context, event *MessageReceivedEvent) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
hooks := r.messageReceived
|
hooks := r.messageReceived
|
||||||
|
|
@ -231,7 +371,7 @@ func (r *HookRegistry) TriggerBeforeToolCall(ctx context.Context, event *BeforeT
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerAfterToolCall fires all after_tool_call handlers concurrently.
|
// TriggerAfterToolCall fires all after_tool_call handlers concurrently.
|
||||||
// Handlers must not mutate the event.
|
// Handler mutations are isolated per hook invocation and are not propagated.
|
||||||
func (r *HookRegistry) TriggerAfterToolCall(ctx context.Context, event *AfterToolCallEvent) {
|
func (r *HookRegistry) TriggerAfterToolCall(ctx context.Context, event *AfterToolCallEvent) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
hooks := r.afterToolCall
|
hooks := r.afterToolCall
|
||||||
|
|
@ -240,7 +380,7 @@ func (r *HookRegistry) TriggerAfterToolCall(ctx context.Context, event *AfterToo
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerLLMInput fires all llm_input handlers concurrently.
|
// TriggerLLMInput fires all llm_input handlers concurrently.
|
||||||
// Handlers must not mutate the event.
|
// Handler mutations are isolated per hook invocation and are not propagated.
|
||||||
func (r *HookRegistry) TriggerLLMInput(ctx context.Context, event *LLMInputEvent) {
|
func (r *HookRegistry) TriggerLLMInput(ctx context.Context, event *LLMInputEvent) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
hooks := r.llmInput
|
hooks := r.llmInput
|
||||||
|
|
@ -249,7 +389,7 @@ func (r *HookRegistry) TriggerLLMInput(ctx context.Context, event *LLMInputEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerLLMOutput fires all llm_output handlers concurrently.
|
// TriggerLLMOutput fires all llm_output handlers concurrently.
|
||||||
// Handlers must not mutate the event.
|
// Handler mutations are isolated per hook invocation and are not propagated.
|
||||||
func (r *HookRegistry) TriggerLLMOutput(ctx context.Context, event *LLMOutputEvent) {
|
func (r *HookRegistry) TriggerLLMOutput(ctx context.Context, event *LLMOutputEvent) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
hooks := r.llmOutput
|
hooks := r.llmOutput
|
||||||
|
|
@ -258,7 +398,7 @@ func (r *HookRegistry) TriggerLLMOutput(ctx context.Context, event *LLMOutputEve
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerSessionStart fires all session_start handlers concurrently.
|
// TriggerSessionStart fires all session_start handlers concurrently.
|
||||||
// Handlers must not mutate the event.
|
// Handler mutations are isolated per hook invocation and are not propagated.
|
||||||
func (r *HookRegistry) TriggerSessionStart(ctx context.Context, event *SessionEvent) {
|
func (r *HookRegistry) TriggerSessionStart(ctx context.Context, event *SessionEvent) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
hooks := r.sessionStart
|
hooks := r.sessionStart
|
||||||
|
|
@ -267,7 +407,7 @@ func (r *HookRegistry) TriggerSessionStart(ctx context.Context, event *SessionEv
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerSessionEnd fires all session_end handlers concurrently.
|
// TriggerSessionEnd fires all session_end handlers concurrently.
|
||||||
// Handlers must not mutate the event.
|
// Handler mutations are isolated per hook invocation and are not propagated.
|
||||||
func (r *HookRegistry) TriggerSessionEnd(ctx context.Context, event *SessionEvent) {
|
func (r *HookRegistry) TriggerSessionEnd(ctx context.Context, event *SessionEvent) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
hooks := r.sessionEnd
|
hooks := r.sessionEnd
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewHookRegistry(t *testing.T) {
|
func TestNewHookRegistry(t *testing.T) {
|
||||||
|
|
@ -96,6 +98,91 @@ func TestVoidHooksConcurrent(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVoidHooksReceiveIsolatedMessageReceivedEvents(t *testing.T) {
|
||||||
|
r := NewHookRegistry()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
r.OnMessageReceived("mutator-a", 0, func(_ context.Context, e *MessageReceivedEvent) error {
|
||||||
|
e.Content = "changed-a"
|
||||||
|
e.Media[0] = "changed-media-a"
|
||||||
|
e.Metadata["k"] = "changed-a"
|
||||||
|
e.Metadata["new-a"] = "x"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
r.OnMessageReceived("mutator-b", 1, func(_ context.Context, e *MessageReceivedEvent) error {
|
||||||
|
e.Content = "changed-b"
|
||||||
|
e.Media = append(e.Media, "extra")
|
||||||
|
e.Metadata["k"] = "changed-b"
|
||||||
|
e.Metadata["new-b"] = "y"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
event := &MessageReceivedEvent{
|
||||||
|
Content: "original",
|
||||||
|
Media: []string{"m1"},
|
||||||
|
Metadata: map[string]string{"k": "v"},
|
||||||
|
}
|
||||||
|
r.TriggerMessageReceived(ctx, event)
|
||||||
|
|
||||||
|
if event.Content != "original" {
|
||||||
|
t.Fatalf("expected original content to remain unchanged, got %q", event.Content)
|
||||||
|
}
|
||||||
|
if len(event.Media) != 1 || event.Media[0] != "m1" {
|
||||||
|
t.Fatalf("expected original media to remain unchanged, got %#v", event.Media)
|
||||||
|
}
|
||||||
|
if got := event.Metadata["k"]; got != "v" {
|
||||||
|
t.Fatalf("expected metadata[k] to remain v, got %q", got)
|
||||||
|
}
|
||||||
|
if _, ok := event.Metadata["new-a"]; ok {
|
||||||
|
t.Fatal("unexpected mutation leaked from hook mutator-a")
|
||||||
|
}
|
||||||
|
if _, ok := event.Metadata["new-b"]; ok {
|
||||||
|
t.Fatal("unexpected mutation leaked from hook mutator-b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVoidHooksReceiveIsolatedAfterToolCallEvents(t *testing.T) {
|
||||||
|
r := NewHookRegistry()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
r.OnAfterToolCall("mutator-a", 0, func(_ context.Context, e *AfterToolCallEvent) error {
|
||||||
|
e.Args["k"] = "changed-a"
|
||||||
|
e.Result.ForLLM = "mutated-a"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
r.OnAfterToolCall("mutator-b", 1, func(_ context.Context, e *AfterToolCallEvent) error {
|
||||||
|
e.Args["k"] = "changed-b"
|
||||||
|
e.Args["new"] = "v"
|
||||||
|
e.Result.ForUser = "mutated-b"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
event := &AfterToolCallEvent{
|
||||||
|
ToolName: "shell",
|
||||||
|
Args: map[string]any{"k": "original"},
|
||||||
|
Result: &tools.ToolResult{
|
||||||
|
ForLLM: "for-llm",
|
||||||
|
ForUser: "for-user",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a local copy so we can compare immutable expectations.
|
||||||
|
r.TriggerAfterToolCall(ctx, event)
|
||||||
|
|
||||||
|
if got := event.Args["k"]; got != "original" {
|
||||||
|
t.Fatalf("expected args[k] to remain original, got %#v", got)
|
||||||
|
}
|
||||||
|
if _, ok := event.Args["new"]; ok {
|
||||||
|
t.Fatal("unexpected args mutation leaked from hook")
|
||||||
|
}
|
||||||
|
if event.Result.ForLLM != "for-llm" {
|
||||||
|
t.Fatalf("expected original result.ForLLM to remain unchanged, got %q", event.Result.ForLLM)
|
||||||
|
}
|
||||||
|
if event.Result.ForUser != "for-user" {
|
||||||
|
t.Fatalf("expected original result.ForUser to remain unchanged, got %q", event.Result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModifyingHookPriority(t *testing.T) {
|
func TestModifyingHookPriority(t *testing.T) {
|
||||||
r := NewHookRegistry()
|
r := NewHookRegistry()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue