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:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
|
||||
@@ -36,6 +37,16 @@ type MCPTool struct {
|
||||
mediaStore media.MediaStore
|
||||
workspace string
|
||||
maxInlineTextRunes int
|
||||
runtimeEvents runtimeevents.Bus
|
||||
}
|
||||
|
||||
// MCPToolCallPayload describes MCP tool execution runtime events.
|
||||
type MCPToolCallPayload struct {
|
||||
Server string `json:"server"`
|
||||
Tool string `json:"tool"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewMCPTool creates a new MCP tool wrapper
|
||||
@@ -62,6 +73,11 @@ func (t *MCPTool) SetMaxInlineTextRunes(limit int) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetEventPublisher injects the runtime event bus used for MCP tool observations.
|
||||
func (t *MCPTool) SetEventPublisher(eventBus runtimeevents.Bus) {
|
||||
t.runtimeEvents = eventBus
|
||||
}
|
||||
|
||||
const maxMCPInlineTextRunes = 16 * 1024
|
||||
|
||||
// sanitizeIdentifierComponent normalizes a string so it can be safely used
|
||||
@@ -237,26 +253,74 @@ func (t *MCPTool) Parameters() map[string]any {
|
||||
|
||||
// Execute executes the MCP tool
|
||||
func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
startedAt := time.Now()
|
||||
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallStart, startedAt, false, "")
|
||||
|
||||
result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)
|
||||
if err != nil {
|
||||
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, err.Error())
|
||||
return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
nilErr := fmt.Errorf("MCP tool returned nil result without error")
|
||||
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, nilErr.Error())
|
||||
return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr)
|
||||
}
|
||||
|
||||
// Handle error result from server
|
||||
if result.IsError {
|
||||
errMsg := extractContentText(result.Content)
|
||||
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, errMsg)
|
||||
return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)).
|
||||
WithError(fmt.Errorf("MCP tool error: %s", errMsg))
|
||||
}
|
||||
|
||||
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, false, "")
|
||||
return t.normalizeResultContent(ctx, result.Content)
|
||||
}
|
||||
|
||||
func (t *MCPTool) publishRuntimeEvent(
|
||||
ctx context.Context,
|
||||
kind runtimeevents.Kind,
|
||||
startedAt time.Time,
|
||||
isError bool,
|
||||
errMsg string,
|
||||
) {
|
||||
if t == nil || t.runtimeEvents == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scope := runtimeevents.Scope{
|
||||
AgentID: toolshared.ToolAgentID(ctx),
|
||||
SessionKey: toolshared.ToolSessionKey(ctx),
|
||||
Channel: toolshared.ToolChannel(ctx),
|
||||
ChatID: toolshared.ToolChatID(ctx),
|
||||
MessageID: toolshared.ToolMessageID(ctx),
|
||||
}
|
||||
payload := MCPToolCallPayload{
|
||||
Server: t.serverName,
|
||||
Tool: t.tool.Name,
|
||||
DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
IsError: isError,
|
||||
Error: errMsg,
|
||||
}
|
||||
severity := runtimeevents.SeverityInfo
|
||||
if isError {
|
||||
severity = runtimeevents.SeverityError
|
||||
}
|
||||
|
||||
publishCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
t.runtimeEvents.Publish(publishCtx, runtimeevents.Event{
|
||||
Kind: kind,
|
||||
Source: runtimeevents.Source{Component: "mcp", Name: t.serverName},
|
||||
Scope: scope,
|
||||
Severity: severity,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// extractContentText extracts text from MCP content array
|
||||
func extractContentText(content []mcp.Content) string {
|
||||
var parts []string
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
|
||||
)
|
||||
@@ -299,6 +301,72 @@ func TestMCPTool_Execute_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPTool_Execute_PublishesRuntimeEvents(t *testing.T) {
|
||||
eventBus := runtimeevents.NewBus()
|
||||
defer func() {
|
||||
if err := eventBus.Close(); err != nil {
|
||||
t.Errorf("event bus close failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, eventsCh, err := eventBus.Channel().OfKind(
|
||||
runtimeevents.KindMCPToolCallStart,
|
||||
runtimeevents.KindMCPToolCallEnd,
|
||||
).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-tool-events", Buffer: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("SubscribeChan failed: %v", err)
|
||||
}
|
||||
|
||||
manager := &MockMCPManager{}
|
||||
mcpTool := NewMCPTool(manager, "github", &mcp.Tool{Name: "search_repos"})
|
||||
mcpTool.SetEventPublisher(eventBus)
|
||||
|
||||
ctx := toolshared.WithToolContext(context.Background(), "telegram", "chat-1")
|
||||
ctx = toolshared.WithToolMessageContext(ctx, "msg-1", "")
|
||||
ctx = toolshared.WithToolSessionContext(ctx, "main", "session-1", nil)
|
||||
result := mcpTool.Execute(ctx, map[string]any{"query": "picoclaw"})
|
||||
if result == nil || result.IsError {
|
||||
t.Fatalf("Execute result = %+v", result)
|
||||
}
|
||||
|
||||
started := receiveMCPToolRuntimeEvent(t, eventsCh)
|
||||
if started.Kind != runtimeevents.KindMCPToolCallStart ||
|
||||
started.Scope.AgentID != "main" ||
|
||||
started.Scope.SessionKey != "session-1" ||
|
||||
started.Scope.Channel != "telegram" ||
|
||||
started.Scope.ChatID != "chat-1" ||
|
||||
started.Scope.MessageID != "msg-1" {
|
||||
t.Fatalf("started event = %+v", started)
|
||||
}
|
||||
|
||||
ended := receiveMCPToolRuntimeEvent(t, eventsCh)
|
||||
if ended.Kind != runtimeevents.KindMCPToolCallEnd || ended.Severity != runtimeevents.SeverityInfo {
|
||||
t.Fatalf("ended event = %+v", ended)
|
||||
}
|
||||
payload, ok := ended.Payload.(MCPToolCallPayload)
|
||||
if !ok {
|
||||
t.Fatalf("ended payload = %T, want MCPToolCallPayload", ended.Payload)
|
||||
}
|
||||
if payload.Server != "github" || payload.Tool != "search_repos" || payload.IsError {
|
||||
t.Fatalf("ended payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveMCPToolRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case evt, ok := <-ch:
|
||||
if !ok {
|
||||
t.Fatal("runtime event channel closed before expected event")
|
||||
}
|
||||
return evt
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for runtime event")
|
||||
return runtimeevents.Event{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPTool_Execute_ManagerError tests execution when manager returns error
|
||||
func TestMCPTool_Execute_ManagerError(t *testing.T) {
|
||||
manager := &MockMCPManager{
|
||||
|
||||
Reference in New Issue
Block a user