mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(events): publish runtime service events
Migrate hook observation to runtime events and update the process hook notification protocol. Add runtime event publication for message bus failures, channel lifecycle/outbound flow, gateway reloads, MCP server state, and MCP tool calls. Validation: go test ./pkg/events/... ./pkg/bus ./pkg/agent ./pkg/channels ./pkg/mcp ./pkg/tools/integration ./pkg/gateway; make lint
This commit is contained in:
@@ -206,3 +206,11 @@ func (al *AgentLoop) RuntimeEventStats() runtimeevents.Stats {
|
||||
}
|
||||
return al.runtimeEvents.Stats()
|
||||
}
|
||||
|
||||
// RuntimeEventBus returns the runtime event bus used by the agent loop.
|
||||
func (al *AgentLoop) RuntimeEventBus() runtimeevents.Bus {
|
||||
if al == nil {
|
||||
return nil
|
||||
}
|
||||
return al.runtimeEvents
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func NewAgentLoop(
|
||||
al.ownsRuntimeEvents = true
|
||||
}
|
||||
al.providerFactory = providers.CreateProviderFromConfig
|
||||
al.hooks = NewHookManager(eventBus)
|
||||
al.hooks = NewHookManagerWithRuntimeEvents(eventBus, al.runtimeEvents.Channel())
|
||||
configureHookManagerFromConfig(al.hooks, cfg)
|
||||
al.contextManager = al.resolveContextManager()
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||
}
|
||||
|
||||
al.mcp.initOnce.Do(func() {
|
||||
mcpManager := mcp.NewManager()
|
||||
mcpManager := mcp.NewManager(mcp.WithRuntimeEvents(al.runtimeEvents))
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
workspacePath := al.cfg.WorkspacePath()
|
||||
@@ -164,6 +164,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||
mcpTool.SetWorkspace(agent.Workspace)
|
||||
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
|
||||
mcpTool.SetEventPublisher(al.runtimeEvents)
|
||||
|
||||
if registerAsHidden {
|
||||
agent.Tools.RegisterHidden(mcpTool)
|
||||
|
||||
+12
-5
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
)
|
||||
|
||||
type hookRuntime struct {
|
||||
@@ -295,10 +296,11 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error)
|
||||
case "", "*", "all":
|
||||
return nil, true, nil
|
||||
default:
|
||||
if _, ok := validKinds[kind]; !ok {
|
||||
normalizedKind, ok := validKinds[kind]
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("unsupported observe event %q", kind)
|
||||
}
|
||||
normalized = append(normalized, kind)
|
||||
normalized = append(normalized, normalizedKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,10 +310,15 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error)
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func validHookEventKinds() map[string]struct{} {
|
||||
kinds := make(map[string]struct{}, int(eventKindCount))
|
||||
func validHookEventKinds() map[string]string {
|
||||
kinds := make(map[string]string, int(eventKindCount)*2)
|
||||
for kind := EventKind(0); kind < eventKindCount; kind++ {
|
||||
kinds[kind.String()] = struct{}{}
|
||||
runtimeKind := runtimeKindForAgentEvent(kind).String()
|
||||
kinds[kind.String()] = runtimeKind
|
||||
kinds[runtimeKind] = runtimeKind
|
||||
}
|
||||
kinds[runtimeevents.KindAgentToolExecStart.String()] = runtimeevents.KindAgentToolExecStart.String()
|
||||
kinds[runtimeevents.KindAgentToolExecEnd.String()] = runtimeevents.KindAgentToolExecEnd.String()
|
||||
kinds[runtimeevents.KindAgentToolExecSkipped.String()] = runtimeevents.KindAgentToolExecSkipped.String()
|
||||
return kinds
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
@@ -155,7 +156,25 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T)
|
||||
t.Fatalf("expected process model, got %q", lastModel)
|
||||
}
|
||||
|
||||
waitForFileContains(t, eventLog, "turn_end")
|
||||
waitForFileContains(t, eventLog, "agent.turn.end")
|
||||
}
|
||||
|
||||
func TestProcessHookObserveKindsFromConfigAcceptsRuntimeNames(t *testing.T) {
|
||||
kinds, enabled, err := processHookObserveKindsFromConfig([]string{
|
||||
"tool_exec_start",
|
||||
"agent.tool.exec_end",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processHookObserveKindsFromConfig failed: %v", err)
|
||||
}
|
||||
if !enabled {
|
||||
t.Fatal("expected observe to be enabled")
|
||||
}
|
||||
|
||||
want := []string{"agent.tool.exec_start", "agent.tool.exec_end"}
|
||||
if !slices.Equal(kinds, want) {
|
||||
t.Fatalf("observe kinds = %v, want %v", kinds, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testing.T) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/isolation"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
@@ -188,13 +189,26 @@ func (ph *ProcessHook) OnEvent(ctx context.Context, evt Event) error {
|
||||
return nil
|
||||
}
|
||||
if len(ph.observeKinds) > 0 {
|
||||
if _, ok := ph.observeKinds[evt.Kind.String()]; !ok {
|
||||
kind := runtimeKindForAgentEvent(evt.Kind).String()
|
||||
if _, ok := ph.observeKinds[kind]; !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ph.notify(ctx, "hook.event", evt)
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
|
||||
if ph == nil || !ph.opts.Observe {
|
||||
return nil
|
||||
}
|
||||
if len(ph.observeKinds) > 0 {
|
||||
if _, ok := ph.observeKinds[evt.Kind.String()]; !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ph.notify(ctx, "hook.runtime_event", evt)
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestAgentLoop_MountProcessHook_LLMAndObserver(t *testing.T) {
|
||||
t.Fatalf("expected process model, got %q", lastModel)
|
||||
}
|
||||
|
||||
waitForFileContains(t, eventLog, "turn_end")
|
||||
waitForFileContains(t, eventLog, "agent.turn.end")
|
||||
}
|
||||
|
||||
func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) {
|
||||
@@ -350,10 +350,12 @@ func runProcessHookHelper() error {
|
||||
}
|
||||
|
||||
if msg.ID == 0 {
|
||||
if msg.Method == "hook.event" && eventLog != "" {
|
||||
if (msg.Method == "hook.event" || msg.Method == "hook.runtime_event") && eventLog != "" {
|
||||
var evt map[string]any
|
||||
if err := json.Unmarshal(msg.Params, &evt); err == nil {
|
||||
if rawKind, ok := evt["Kind"].(float64); ok {
|
||||
if kind, ok := evt["kind"].(string); ok {
|
||||
_ = os.WriteFile(eventLog, []byte(kind+"\n"), 0o644)
|
||||
} else if rawKind, ok := evt["Kind"].(float64); ok {
|
||||
kind := EventKind(rawKind)
|
||||
_ = os.WriteFile(eventLog, []byte(kind.String()+"\n"), 0o644)
|
||||
}
|
||||
|
||||
+99
-7
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
@@ -75,6 +76,10 @@ type EventObserver interface {
|
||||
OnEvent(ctx context.Context, evt Event) error
|
||||
}
|
||||
|
||||
type RuntimeEventObserver interface {
|
||||
OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error
|
||||
}
|
||||
|
||||
type LLMInterceptor interface {
|
||||
BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision, error)
|
||||
AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision, error)
|
||||
@@ -193,6 +198,7 @@ func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse {
|
||||
|
||||
type HookManager struct {
|
||||
eventBus *EventBus
|
||||
runtimeEvents runtimeevents.EventChannel
|
||||
observerTimeout time.Duration
|
||||
interceptorTimeout time.Duration
|
||||
approvalTimeout time.Duration
|
||||
@@ -201,28 +207,56 @@ type HookManager struct {
|
||||
hooks map[string]HookRegistration
|
||||
ordered []HookRegistration
|
||||
|
||||
sub EventSubscription
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
sub EventSubscription
|
||||
runtimeSub runtimeevents.Subscription
|
||||
done chan struct{}
|
||||
runtimeDone chan struct{}
|
||||
runtimeObserveEnabled bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewHookManager(eventBus *EventBus) *HookManager {
|
||||
return NewHookManagerWithRuntimeEvents(eventBus, nil)
|
||||
}
|
||||
|
||||
func NewHookManagerWithRuntimeEvents(eventBus *EventBus, runtimeEvents runtimeevents.EventChannel) *HookManager {
|
||||
hm := &HookManager{
|
||||
eventBus: eventBus,
|
||||
runtimeEvents: runtimeEvents,
|
||||
observerTimeout: defaultHookObserverTimeout,
|
||||
interceptorTimeout: defaultHookInterceptorTimeout,
|
||||
approvalTimeout: defaultHookApprovalTimeout,
|
||||
hooks: make(map[string]HookRegistration),
|
||||
done: make(chan struct{}),
|
||||
runtimeDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
if eventBus == nil {
|
||||
if eventBus != nil {
|
||||
hm.sub = eventBus.Subscribe(hookObserverBufferSize)
|
||||
go hm.dispatchEvents()
|
||||
} else {
|
||||
close(hm.done)
|
||||
return hm
|
||||
}
|
||||
|
||||
hm.sub = eventBus.Subscribe(hookObserverBufferSize)
|
||||
go hm.dispatchEvents()
|
||||
if runtimeEvents != nil {
|
||||
sub, ch, err := runtimeEvents.SubscribeChan(context.Background(), runtimeevents.SubscribeOptions{
|
||||
Name: "hook-manager-observer",
|
||||
Buffer: hookObserverBufferSize,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WarnCF("hooks", "Failed to subscribe runtime events for hooks", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
close(hm.runtimeDone)
|
||||
} else {
|
||||
hm.runtimeSub = sub
|
||||
hm.runtimeObserveEnabled = true
|
||||
go hm.dispatchRuntimeEvents(ch)
|
||||
}
|
||||
} else {
|
||||
close(hm.runtimeDone)
|
||||
}
|
||||
|
||||
return hm
|
||||
}
|
||||
|
||||
@@ -235,7 +269,15 @@ func (hm *HookManager) Close() {
|
||||
if hm.eventBus != nil {
|
||||
hm.eventBus.Unsubscribe(hm.sub.ID)
|
||||
}
|
||||
if hm.runtimeSub != nil {
|
||||
if err := hm.runtimeSub.Close(); err != nil {
|
||||
logger.WarnCF("hooks", "Failed to close runtime event hook subscription", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
<-hm.done
|
||||
<-hm.runtimeDone
|
||||
hm.closeAllHooks()
|
||||
})
|
||||
}
|
||||
@@ -297,6 +339,11 @@ func (hm *HookManager) dispatchEvents() {
|
||||
|
||||
for evt := range hm.sub.C {
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
if hm.runtimeObserveEnabled {
|
||||
if _, ok := reg.Hook.(RuntimeEventObserver); ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
observer, ok := reg.Hook.(EventObserver)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -306,6 +353,20 @@ func (hm *HookManager) dispatchEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) dispatchRuntimeEvents(ch <-chan runtimeevents.Event) {
|
||||
defer close(hm.runtimeDone)
|
||||
|
||||
for evt := range ch {
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
observer, ok := reg.Hook.(RuntimeEventObserver)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hm.runRuntimeObserver(reg.Name, observer, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision) {
|
||||
if hm == nil || req == nil {
|
||||
return req, HookDecision{Action: HookActionContinue}
|
||||
@@ -608,6 +669,37 @@ func (hm *HookManager) runObserver(name string, observer EventObserver, evt Even
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) runRuntimeObserver(
|
||||
name string,
|
||||
observer RuntimeEventObserver,
|
||||
evt runtimeevents.Event,
|
||||
) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hm.observerTimeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- observer.OnRuntimeEvent(ctx, evt)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
logger.WarnCF("hooks", "Runtime event observer failed", map[string]any{
|
||||
"hook": name,
|
||||
"event": evt.Kind.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
case <-ctx.Done():
|
||||
logger.WarnCF("hooks", "Runtime event observer timed out", map[string]any{
|
||||
"hook": name,
|
||||
"event": evt.Kind.String(),
|
||||
"timeout_ms": hm.observerTimeout.Milliseconds(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) callBeforeLLM(
|
||||
parent context.Context,
|
||||
name string,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
@@ -150,6 +151,31 @@ func (h *llmObserverHook) AfterLLM(
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
type dualRuntimeObserverHook struct {
|
||||
legacyCh chan Event
|
||||
runtimeCh chan runtimeevents.Event
|
||||
}
|
||||
|
||||
func (h *dualRuntimeObserverHook) OnEvent(ctx context.Context, evt Event) error {
|
||||
if evt.Kind == EventKindTurnEnd {
|
||||
select {
|
||||
case h.legacyCh <- evt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *dualRuntimeObserverHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
|
||||
if evt.Kind == runtimeevents.KindAgentTurnEnd {
|
||||
select {
|
||||
case h.runtimeCh <- evt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type llmSystemRewriteHook struct{}
|
||||
|
||||
func (h *llmSystemRewriteHook) BeforeLLM(
|
||||
@@ -498,6 +524,65 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_Hooks_RuntimeObserverPreferredOverLegacyObserver(t *testing.T) {
|
||||
provider := &llmHookTestProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
hook := &dualRuntimeObserverHook{
|
||||
legacyCh: make(chan Event, 1),
|
||||
runtimeCh: make(chan runtimeevents.Event, 1),
|
||||
}
|
||||
if err := al.MountHook(NamedHook("runtime-observer", hook)); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "hello",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
InboundContext: &bus.InboundContext{
|
||||
Channel: "cli",
|
||||
Account: "default",
|
||||
ChatID: "direct",
|
||||
ChatType: "direct",
|
||||
SenderID: "hook-user",
|
||||
MessageID: "msg-1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "provider content" {
|
||||
t.Fatalf("expected provider content, got %q", resp)
|
||||
}
|
||||
|
||||
select {
|
||||
case evt := <-hook.runtimeCh:
|
||||
if evt.Kind != runtimeevents.KindAgentTurnEnd {
|
||||
t.Fatalf("runtime observer kind = %q", evt.Kind)
|
||||
}
|
||||
if evt.Scope.SessionKey != "session-1" ||
|
||||
evt.Scope.Channel != "cli" ||
|
||||
evt.Scope.ChatID != "direct" ||
|
||||
evt.Scope.MessageID != "msg-1" {
|
||||
t.Fatalf("runtime observer scope = %+v", evt.Scope)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for runtime observer event")
|
||||
}
|
||||
|
||||
select {
|
||||
case evt := <-hook.legacyCh:
|
||||
t.Fatalf("legacy observer unexpectedly received %v", evt.Kind)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) {
|
||||
provider := &llmHookTestProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
|
||||
Reference in New Issue
Block a user