diff --git a/cmd/agent_chat.go b/cmd/agent_chat.go index cc02ee5f..953f8b43 100644 --- a/cmd/agent_chat.go +++ b/cmd/agent_chat.go @@ -1,34 +1,15 @@ package cmd import ( - "bufio" - "context" - "encoding/json" "fmt" - "log/slog" "net" "os" - "os/signal" - "path/filepath" - "strings" - "sync" "time" - "github.com/google/uuid" - "github.com/gorilla/websocket" "github.com/spf13/cobra" - "github.com/nextlevelbuilder/goclaw/internal/agent" - "github.com/nextlevelbuilder/goclaw/internal/bootstrap" - "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sessions" - "github.com/nextlevelbuilder/goclaw/internal/skills" - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/store/file" - "github.com/nextlevelbuilder/goclaw/internal/tools" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) func agentChatCmd() *cobra.Command { @@ -102,419 +83,3 @@ func isGatewayRunning(addr string) bool { conn.Close() return true } - -// ============================================================ -// CLIENT MODE — connect to running gateway via WebSocket -// ============================================================ - -func runClientMode(cfg *config.Config, addr, agentName, message, sessionKey string) { - wsURL := fmt.Sprintf("ws://%s/ws", addr) - - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err != nil { - fmt.Fprintf(os.Stderr, "WebSocket connect failed: %v\n", err) - fmt.Fprintf(os.Stderr, "Falling back to standalone mode\n") - runStandaloneMode(cfg, agentName, message, sessionKey) - return - } - defer conn.Close() - - // Authenticate - if err := wsConnect(conn, cfg.Gateway.Token); err != nil { - fmt.Fprintf(os.Stderr, "Gateway auth failed: %v\n", err) - os.Exit(1) - } - - agentCfg := cfg.ResolveAgent(agentName) - - if message != "" { - // One-shot mode - resp, err := wsChatSend(conn, agentName, sessionKey, message) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println(resp) - return - } - - // Interactive REPL - fmt.Fprintf(os.Stderr, "\nGoClaw Interactive Chat (agent: %s, model: %s)\n", agentName, agentCfg.Model) - fmt.Fprintf(os.Stderr, "Session: %s\n", sessionKey) - fmt.Fprintf(os.Stderr, "Type \"exit\" to quit, \"/new\" for new session\n\n") - - scanner := bufio.NewScanner(os.Stdin) - for { - fmt.Fprint(os.Stderr, "You: ") - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - continue - } - if input == "exit" || input == "quit" { - fmt.Fprintln(os.Stderr, "Goodbye!") - return - } - if input == "/new" { - sessionKey = sessions.BuildSessionKey(agentName, "cli", sessions.PeerDirect, uuid.NewString()[:8]) - fmt.Fprintf(os.Stderr, "New session: %s\n\n", sessionKey) - continue - } - - resp, err := wsChatSend(conn, agentName, sessionKey, input) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) - continue - } - fmt.Printf("\n%s\n\n", resp) - } -} - -// wsConnect sends the connect RPC and waits for auth response. -func wsConnect(conn *websocket.Conn, token string) error { - params := map[string]string{} - if token != "" { - params["token"] = token - } - paramsJSON, _ := json.Marshal(params) - - reqFrame := protocol.RequestFrame{ - Type: protocol.FrameTypeRequest, - ID: "connect-1", - Method: protocol.MethodConnect, - Params: paramsJSON, - } - - if err := conn.WriteJSON(reqFrame); err != nil { - return fmt.Errorf("send connect: %w", err) - } - - var resp protocol.ResponseFrame - if err := conn.ReadJSON(&resp); err != nil { - return fmt.Errorf("read connect response: %w", err) - } - if !resp.OK { - if resp.Error != nil { - return fmt.Errorf("connect rejected: %s", resp.Error.Message) - } - return fmt.Errorf("connect rejected") - } - - return nil -} - -// wsChatSend sends a chat.send RPC and waits for the response, -// displaying events (tool calls, chunks) in real-time. -func wsChatSend(conn *websocket.Conn, agentID, sessionKey, message string) (string, error) { - reqID := uuid.NewString()[:8] - params, _ := json.Marshal(map[string]interface{}{ - "message": message, - "agentId": agentID, - "sessionKey": sessionKey, - "stream": true, - }) - - reqFrame := protocol.RequestFrame{ - Type: protocol.FrameTypeRequest, - ID: reqID, - Method: protocol.MethodChatSend, - Params: params, - } - - if err := conn.WriteJSON(reqFrame); err != nil { - return "", fmt.Errorf("send chat: %w", err) - } - - // Read frames until we get our response - var finalContent string - for { - _, rawMsg, err := conn.ReadMessage() - if err != nil { - return "", fmt.Errorf("read: %w", err) - } - - frameType, _ := protocol.ParseFrameType(rawMsg) - - switch frameType { - case protocol.FrameTypeResponse: - var resp protocol.ResponseFrame - if err := json.Unmarshal(rawMsg, &resp); err != nil { - continue - } - if resp.ID != reqID { - continue // response for a different request - } - if !resp.OK { - if resp.Error != nil { - return "", fmt.Errorf("agent error: %s", resp.Error.Message) - } - return "", fmt.Errorf("agent error (unknown)") - } - // Extract content from payload - if payload, ok := resp.Payload.(map[string]interface{}); ok { - if content, ok := payload["content"].(string); ok && content != "" { - finalContent = content - } - } - return finalContent, nil - - case protocol.FrameTypeEvent: - var evt protocol.EventFrame - if err := json.Unmarshal(rawMsg, &evt); err != nil { - continue - } - handleCLIEvent(evt) - } - } -} - -// handleCLIEvent displays agent events in the terminal. -func handleCLIEvent(evt protocol.EventFrame) { - payload, ok := evt.Payload.(map[string]interface{}) - if !ok { - return - } - - evtType, _ := payload["type"].(string) - - switch evt.Event { - case protocol.EventAgent: - switch evtType { - case protocol.AgentEventToolCall: - if p, ok := payload["payload"].(map[string]interface{}); ok { - name, _ := p["toolName"].(string) - if name == "" { - name, _ = p["name"].(string) - } - fmt.Fprintf(os.Stderr, " [tool] %s\n", name) - } - case protocol.AgentEventToolResult: - if p, ok := payload["payload"].(map[string]interface{}); ok { - isErr, _ := p["is_error"].(bool) - name, _ := p["toolName"].(string) - if name == "" { - name, _ = p["name"].(string) - } - if isErr { - fmt.Fprintf(os.Stderr, " [tool] %s -> error\n", name) - } - } - } - - case protocol.EventChat: - switch evtType { - case protocol.ChatEventChunk: - if content, ok := payload["content"].(string); ok { - fmt.Print(content) - } - } - } -} - -// ============================================================ -// STANDALONE MODE — bootstrap mini agent loop -// ============================================================ - -func runStandaloneMode(cfg *config.Config, agentName, message, sessionKey string) { - loop, sessStore, agentCfg := bootstrapStandaloneAgent(cfg, agentName) - - chatFn := func(msg string) (string, error) { - runID := fmt.Sprintf("cli-%s", uuid.NewString()[:8]) - result, err := loop.Run(context.Background(), agent.RunRequest{ - SessionKey: sessionKey, - Message: msg, - Channel: "cli", - ChatID: "local", - PeerKind: "direct", - RunID: runID, - }) - if err != nil { - return "", err - } - return result.Content, nil - } - - _ = sessStore // keep reference for session persistence - - if message != "" { - resp, err := chatFn(message) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println(resp) - return - } - - // Interactive REPL - fmt.Fprintf(os.Stderr, "\nGoClaw Interactive Chat — Standalone Mode\n") - fmt.Fprintf(os.Stderr, "Agent: %s | Model: %s\n", agentName, agentCfg.Model) - fmt.Fprintf(os.Stderr, "Session: %s\n", sessionKey) - fmt.Fprintf(os.Stderr, "Type \"exit\" to quit, \"/new\" for new session\n\n") - - // Handle Ctrl+C gracefully - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - - scanner := bufio.NewScanner(os.Stdin) - for { - select { - case <-ctx.Done(): - fmt.Fprintln(os.Stderr, "\nGoodbye!") - return - default: - } - - fmt.Fprint(os.Stderr, "You: ") - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - continue - } - if input == "exit" || input == "quit" { - fmt.Fprintln(os.Stderr, "Goodbye!") - return - } - if input == "/new" { - sessionKey = sessions.BuildSessionKey(agentName, "cli", sessions.PeerDirect, uuid.NewString()[:8]) - fmt.Fprintf(os.Stderr, "New session: %s\n\n", sessionKey) - continue - } - - resp, err := chatFn(input) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) - continue - } - fmt.Printf("\n%s\n\n", resp) - } -} - -// bootstrapStandaloneAgent creates a minimal agent loop for CLI usage. -func bootstrapStandaloneAgent(cfg *config.Config, agentName string) (*agent.Loop, store.SessionStore, config.AgentDefaults) { - agentCfg := cfg.ResolveAgent(agentName) - workspace := config.ExpandHome(agentCfg.Workspace) - if !filepath.IsAbs(workspace) { - workspace, _ = filepath.Abs(workspace) - } - - // Ensure workspace exists - os.MkdirAll(workspace, 0755) - - // 1. Provider - providerReg := providers.NewRegistry() - registerProviders(providerReg, cfg) - - provider, err := providerReg.Get(agentCfg.Provider) - if err != nil { - names := providerReg.List() - if len(names) == 0 { - fmt.Fprintf(os.Stderr, "Error: no providers configured. Run 'goclaw onboard' first.\n") - os.Exit(1) - } - provider, _ = providerReg.Get(names[0]) - slog.Warn("configured provider not found, using fallback", "wanted", agentCfg.Provider, "using", names[0]) - } - - // 2. Sessions (wrap file-based manager in store adapter) - sessStorage := config.ExpandHome(cfg.Sessions.Storage) - sessStore := file.NewFileSessionStore(sessions.NewManager(sessStorage)) - - // 3. Tools - toolsReg := tools.NewRegistry() - toolsReg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace)) - toolsReg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace)) - toolsReg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace)) - toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace)) - - // Web tools - webSearchTool := tools.NewWebSearchTool(tools.WebSearchConfig{ - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - DDGEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - }) - if webSearchTool != nil { - toolsReg.Register(webSearchTool) - } - toolsReg.Register(tools.NewWebFetchTool(tools.WebFetchConfig{})) - - // 4. Bootstrap files - rawFiles := bootstrap.LoadWorkspaceFiles(workspace) - truncCfg := bootstrap.TruncateConfig{ - MaxCharsPerFile: agentCfg.BootstrapMaxChars, - TotalMaxChars: agentCfg.BootstrapTotalMaxChars, - } - if truncCfg.MaxCharsPerFile <= 0 { - truncCfg.MaxCharsPerFile = bootstrap.DefaultMaxCharsPerFile - } - if truncCfg.TotalMaxChars <= 0 { - truncCfg.TotalMaxChars = bootstrap.DefaultTotalMaxChars - } - contextFiles := bootstrap.BuildContextFiles(rawFiles, truncCfg) - - // 5. Skills - globalSkillsDir := filepath.Join(config.ExpandHome("~/.goclaw"), "skills") - skillsLoader := skills.NewLoader(workspace, globalSkillsDir, "") - toolsReg.Register(tools.NewSkillSearchTool(skillsLoader)) - - // Allow read_file to access skills directories - if readTool, ok := toolsReg.Get("read_file"); ok { - if rt, ok := readTool.(*tools.ReadFileTool); ok { - rt.AllowPaths(globalSkillsDir) - if homeDir, err := os.UserHomeDir(); err == nil { - rt.AllowPaths(filepath.Join(homeDir, ".agents", "skills")) - } - } - } - - // 6. Event display (tool calls on stderr) - var eventMu sync.Mutex - onEvent := func(evt agent.AgentEvent) { - eventMu.Lock() - defer eventMu.Unlock() - - switch evt.Type { - case protocol.AgentEventToolCall: - if p, ok := evt.Payload.(map[string]interface{}); ok { - name, _ := p["name"].(string) - fmt.Fprintf(os.Stderr, " [tool] %s\n", name) - } - case protocol.AgentEventToolResult: - // silent — avoid noisy output - } - } - - // Per-agent skill allowlist - var skillAllowList []string - if spec, ok := cfg.Agents.List[agentName]; ok { - skillAllowList = spec.Skills - } - - // 7. Create agent loop - msgBus := bus.New() - loop := agent.NewLoop(agent.LoopConfig{ - ID: agentName, - Provider: provider, - Model: agentCfg.Model, - ContextWindow: agentCfg.ContextWindow, - MaxIterations: agentCfg.MaxToolIterations, - Workspace: workspace, - Bus: msgBus, - Sessions: sessStore, - Tools: toolsReg, - OnEvent: onEvent, - OwnerIDs: cfg.Gateway.OwnerIDs, - SkillsLoader: skillsLoader, - SkillAllowList: skillAllowList, - HasMemory: false, // skip memory for standalone CLI (avoids SQLite dep issues) - ContextFiles: contextFiles, - CompactionCfg: agentCfg.Compaction, - ContextPruningCfg: agentCfg.ContextPruning, - }) - - return loop, sessStore, agentCfg -} diff --git a/cmd/agent_chat_client.go b/cmd/agent_chat_client.go new file mode 100644 index 00000000..1b6cf3af --- /dev/null +++ b/cmd/agent_chat_client.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/sessions" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +func runClientMode(cfg *config.Config, addr, agentName, message, sessionKey string) { + wsURL := fmt.Sprintf("ws://%s/ws", addr) + + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "WebSocket connect failed: %v\n", err) + fmt.Fprintf(os.Stderr, "Falling back to standalone mode\n") + runStandaloneMode(cfg, agentName, message, sessionKey) + return + } + defer conn.Close() + + // Authenticate + if err := wsConnect(conn, cfg.Gateway.Token); err != nil { + fmt.Fprintf(os.Stderr, "Gateway auth failed: %v\n", err) + os.Exit(1) + } + + agentCfg := cfg.ResolveAgent(agentName) + + if message != "" { + // One-shot mode + resp, err := wsChatSend(conn, agentName, sessionKey, message) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println(resp) + return + } + + // Interactive REPL + fmt.Fprintf(os.Stderr, "\nGoClaw Interactive Chat (agent: %s, model: %s)\n", agentName, agentCfg.Model) + fmt.Fprintf(os.Stderr, "Session: %s\n", sessionKey) + fmt.Fprintf(os.Stderr, "Type \"exit\" to quit, \"/new\" for new session\n\n") + + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Fprint(os.Stderr, "You: ") + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + if input == "exit" || input == "quit" { + fmt.Fprintln(os.Stderr, "Goodbye!") + return + } + if input == "/new" { + sessionKey = sessions.BuildSessionKey(agentName, "cli", sessions.PeerDirect, uuid.NewString()[:8]) + fmt.Fprintf(os.Stderr, "New session: %s\n\n", sessionKey) + continue + } + + resp, err := wsChatSend(conn, agentName, sessionKey, input) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + continue + } + fmt.Printf("\n%s\n\n", resp) + } +} + +// wsConnect sends the connect RPC and waits for auth response. +func wsConnect(conn *websocket.Conn, token string) error { + params := map[string]string{} + if token != "" { + params["token"] = token + } + paramsJSON, _ := json.Marshal(params) + + reqFrame := protocol.RequestFrame{ + Type: protocol.FrameTypeRequest, + ID: "connect-1", + Method: protocol.MethodConnect, + Params: paramsJSON, + } + + if err := conn.WriteJSON(reqFrame); err != nil { + return fmt.Errorf("send connect: %w", err) + } + + var resp protocol.ResponseFrame + if err := conn.ReadJSON(&resp); err != nil { + return fmt.Errorf("read connect response: %w", err) + } + if !resp.OK { + if resp.Error != nil { + return fmt.Errorf("connect rejected: %s", resp.Error.Message) + } + return fmt.Errorf("connect rejected") + } + + return nil +} + +// wsChatSend sends a chat.send RPC and waits for the response, +// displaying events (tool calls, chunks) in real-time. +func wsChatSend(conn *websocket.Conn, agentID, sessionKey, message string) (string, error) { + reqID := uuid.NewString()[:8] + params, _ := json.Marshal(map[string]interface{}{ + "message": message, + "agentId": agentID, + "sessionKey": sessionKey, + "stream": true, + }) + + reqFrame := protocol.RequestFrame{ + Type: protocol.FrameTypeRequest, + ID: reqID, + Method: protocol.MethodChatSend, + Params: params, + } + + if err := conn.WriteJSON(reqFrame); err != nil { + return "", fmt.Errorf("send chat: %w", err) + } + + // Read frames until we get our response + var finalContent string + for { + _, rawMsg, err := conn.ReadMessage() + if err != nil { + return "", fmt.Errorf("read: %w", err) + } + + frameType, _ := protocol.ParseFrameType(rawMsg) + + switch frameType { + case protocol.FrameTypeResponse: + var resp protocol.ResponseFrame + if err := json.Unmarshal(rawMsg, &resp); err != nil { + continue + } + if resp.ID != reqID { + continue // response for a different request + } + if !resp.OK { + if resp.Error != nil { + return "", fmt.Errorf("agent error: %s", resp.Error.Message) + } + return "", fmt.Errorf("agent error (unknown)") + } + // Extract content from payload + if payload, ok := resp.Payload.(map[string]interface{}); ok { + if content, ok := payload["content"].(string); ok && content != "" { + finalContent = content + } + } + return finalContent, nil + + case protocol.FrameTypeEvent: + var evt protocol.EventFrame + if err := json.Unmarshal(rawMsg, &evt); err != nil { + continue + } + handleCLIEvent(evt) + } + } +} + +// handleCLIEvent displays agent events in the terminal. +func handleCLIEvent(evt protocol.EventFrame) { + payload, ok := evt.Payload.(map[string]interface{}) + if !ok { + return + } + + evtType, _ := payload["type"].(string) + + switch evt.Event { + case protocol.EventAgent: + switch evtType { + case protocol.AgentEventToolCall: + if p, ok := payload["payload"].(map[string]interface{}); ok { + name, _ := p["toolName"].(string) + if name == "" { + name, _ = p["name"].(string) + } + fmt.Fprintf(os.Stderr, " [tool] %s\n", name) + } + case protocol.AgentEventToolResult: + if p, ok := payload["payload"].(map[string]interface{}); ok { + isErr, _ := p["is_error"].(bool) + name, _ := p["toolName"].(string) + if name == "" { + name, _ = p["name"].(string) + } + if isErr { + fmt.Fprintf(os.Stderr, " [tool] %s -> error\n", name) + } + } + } + + case protocol.EventChat: + switch evtType { + case protocol.ChatEventChunk: + if content, ok := payload["content"].(string); ok { + fmt.Print(content) + } + } + } +} diff --git a/cmd/agent_chat_standalone.go b/cmd/agent_chat_standalone.go new file mode 100644 index 00000000..2afb4d59 --- /dev/null +++ b/cmd/agent_chat_standalone.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/sessions" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/file" + "github.com/nextlevelbuilder/goclaw/internal/tools" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +func runStandaloneMode(cfg *config.Config, agentName, message, sessionKey string) { + loop, sessStore, agentCfg := bootstrapStandaloneAgent(cfg, agentName) + + chatFn := func(msg string) (string, error) { + runID := fmt.Sprintf("cli-%s", uuid.NewString()[:8]) + result, err := loop.Run(context.Background(), agent.RunRequest{ + SessionKey: sessionKey, + Message: msg, + Channel: "cli", + ChatID: "local", + PeerKind: "direct", + RunID: runID, + }) + if err != nil { + return "", err + } + return result.Content, nil + } + + _ = sessStore // keep reference for session persistence + + if message != "" { + resp, err := chatFn(message) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println(resp) + return + } + + // Interactive REPL + fmt.Fprintf(os.Stderr, "\nGoClaw Interactive Chat — Standalone Mode\n") + fmt.Fprintf(os.Stderr, "Agent: %s | Model: %s\n", agentName, agentCfg.Model) + fmt.Fprintf(os.Stderr, "Session: %s\n", sessionKey) + fmt.Fprintf(os.Stderr, "Type \"exit\" to quit, \"/new\" for new session\n\n") + + // Handle Ctrl+C gracefully + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + scanner := bufio.NewScanner(os.Stdin) + for { + select { + case <-ctx.Done(): + fmt.Fprintln(os.Stderr, "\nGoodbye!") + return + default: + } + + fmt.Fprint(os.Stderr, "You: ") + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + if input == "exit" || input == "quit" { + fmt.Fprintln(os.Stderr, "Goodbye!") + return + } + if input == "/new" { + sessionKey = sessions.BuildSessionKey(agentName, "cli", sessions.PeerDirect, uuid.NewString()[:8]) + fmt.Fprintf(os.Stderr, "New session: %s\n\n", sessionKey) + continue + } + + resp, err := chatFn(input) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + continue + } + fmt.Printf("\n%s\n\n", resp) + } +} + +// bootstrapStandaloneAgent creates a minimal agent loop for CLI usage. +func bootstrapStandaloneAgent(cfg *config.Config, agentName string) (*agent.Loop, store.SessionStore, config.AgentDefaults) { + agentCfg := cfg.ResolveAgent(agentName) + workspace := config.ExpandHome(agentCfg.Workspace) + if !filepath.IsAbs(workspace) { + workspace, _ = filepath.Abs(workspace) + } + + // Ensure workspace exists + os.MkdirAll(workspace, 0755) + + // 1. Provider + providerReg := providers.NewRegistry() + registerProviders(providerReg, cfg) + + provider, err := providerReg.Get(agentCfg.Provider) + if err != nil { + names := providerReg.List() + if len(names) == 0 { + fmt.Fprintf(os.Stderr, "Error: no providers configured. Run 'goclaw onboard' first.\n") + os.Exit(1) + } + provider, _ = providerReg.Get(names[0]) + slog.Warn("configured provider not found, using fallback", "wanted", agentCfg.Provider, "using", names[0]) + } + + // 2. Sessions (wrap file-based manager in store adapter) + sessStorage := config.ExpandHome(cfg.Sessions.Storage) + sessStore := file.NewFileSessionStore(sessions.NewManager(sessStorage)) + + // 3. Tools + toolsReg := tools.NewRegistry() + toolsReg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace)) + toolsReg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace)) + toolsReg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace)) + toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace)) + + // Web tools + webSearchTool := tools.NewWebSearchTool(tools.WebSearchConfig{ + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + DDGEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + }) + if webSearchTool != nil { + toolsReg.Register(webSearchTool) + } + toolsReg.Register(tools.NewWebFetchTool(tools.WebFetchConfig{})) + + // 4. Bootstrap files + rawFiles := bootstrap.LoadWorkspaceFiles(workspace) + truncCfg := bootstrap.TruncateConfig{ + MaxCharsPerFile: agentCfg.BootstrapMaxChars, + TotalMaxChars: agentCfg.BootstrapTotalMaxChars, + } + if truncCfg.MaxCharsPerFile <= 0 { + truncCfg.MaxCharsPerFile = bootstrap.DefaultMaxCharsPerFile + } + if truncCfg.TotalMaxChars <= 0 { + truncCfg.TotalMaxChars = bootstrap.DefaultTotalMaxChars + } + contextFiles := bootstrap.BuildContextFiles(rawFiles, truncCfg) + + // 5. Skills + globalSkillsDir := filepath.Join(config.ExpandHome("~/.goclaw"), "skills") + skillsLoader := skills.NewLoader(workspace, globalSkillsDir, "") + toolsReg.Register(tools.NewSkillSearchTool(skillsLoader)) + + // Allow read_file to access skills directories + if readTool, ok := toolsReg.Get("read_file"); ok { + if rt, ok := readTool.(*tools.ReadFileTool); ok { + rt.AllowPaths(globalSkillsDir) + if homeDir, err := os.UserHomeDir(); err == nil { + rt.AllowPaths(filepath.Join(homeDir, ".agents", "skills")) + } + } + } + + // 6. Event display (tool calls on stderr) + var eventMu sync.Mutex + onEvent := func(evt agent.AgentEvent) { + eventMu.Lock() + defer eventMu.Unlock() + + switch evt.Type { + case protocol.AgentEventToolCall: + if p, ok := evt.Payload.(map[string]interface{}); ok { + name, _ := p["name"].(string) + fmt.Fprintf(os.Stderr, " [tool] %s\n", name) + } + case protocol.AgentEventToolResult: + // silent — avoid noisy output + } + } + + // Per-agent skill allowlist + var skillAllowList []string + if spec, ok := cfg.Agents.List[agentName]; ok { + skillAllowList = spec.Skills + } + + // 7. Create agent loop + msgBus := bus.New() + loop := agent.NewLoop(agent.LoopConfig{ + ID: agentName, + Provider: provider, + Model: agentCfg.Model, + ContextWindow: agentCfg.ContextWindow, + MaxIterations: agentCfg.MaxToolIterations, + Workspace: workspace, + Bus: msgBus, + Sessions: sessStore, + Tools: toolsReg, + OnEvent: onEvent, + OwnerIDs: cfg.Gateway.OwnerIDs, + SkillsLoader: skillsLoader, + SkillAllowList: skillAllowList, + HasMemory: false, // skip memory for standalone CLI (avoids SQLite dep issues) + ContextFiles: contextFiles, + CompactionCfg: agentCfg.Compaction, + ContextPruningCfg: agentCfg.ContextPruning, + }) + + return loop, sessStore, agentCfg +} diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 719dfd84..5916a23e 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -632,66 +632,6 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents } } -// makeCronJobHandler creates a cron job handler that routes through the scheduler's cron lane. -// This ensures per-session concurrency control (same job can't run concurrently) -// and integration with /stop, /stopall commands. -func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg *config.Config) func(job *store.CronJob) (*store.CronJobResult, error) { - return func(job *store.CronJob) (*store.CronJobResult, error) { - agentID := job.AgentID - if agentID == "" { - agentID = cfg.ResolveDefaultAgentID() - } else { - agentID = config.NormalizeAgentID(agentID) - } - - sessionKey := sessions.BuildCronSessionKey(agentID, job.ID) - channel := job.Payload.Channel - if channel == "" { - channel = "cron" - } - - // Schedule through cron lane — scheduler handles agent resolution and concurrency - outCh := sched.Schedule(context.Background(), scheduler.LaneCron, agent.RunRequest{ - SessionKey: sessionKey, - Message: job.Payload.Message, - Channel: channel, - ChatID: job.Payload.To, - UserID: job.UserID, - RunID: fmt.Sprintf("cron:%s", job.ID), - Stream: false, - TraceName: fmt.Sprintf("Cron [%s] - %s", job.Name, agentID), - TraceTags: []string{"cron"}, - }) - - // Block until the scheduled run completes - outcome := <-outCh - if outcome.Err != nil { - return nil, outcome.Err - } - - result := outcome.Result - - // If job wants delivery to a channel, publish outbound - if job.Payload.Deliver && job.Payload.Channel != "" && job.Payload.To != "" { - msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: job.Payload.Channel, - ChatID: job.Payload.To, - Content: result.Content, - }) - } - - cronResult := &store.CronJobResult{ - Content: result.Content, - } - if result.Usage != nil { - cronResult.InputTokens = result.Usage.PromptTokens - cronResult.OutputTokens = result.Usage.CompletionTokens - } - - return cronResult, nil - } -} - // resolveAgentRoute determines which agent should handle a message // based on config bindings. Priority: peer → channel → default. // Matching TS resolve-route.ts binding resolution. diff --git a/cmd/gateway_cron.go b/cmd/gateway_cron.go new file mode 100644 index 00000000..e7afbb45 --- /dev/null +++ b/cmd/gateway_cron.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/scheduler" + "github.com/nextlevelbuilder/goclaw/internal/sessions" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// makeCronJobHandler creates a cron job handler that routes through the scheduler's cron lane. +// This ensures per-session concurrency control (same job can't run concurrently) +// and integration with /stop, /stopall commands. +func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg *config.Config) func(job *store.CronJob) (*store.CronJobResult, error) { + return func(job *store.CronJob) (*store.CronJobResult, error) { + agentID := job.AgentID + if agentID == "" { + agentID = cfg.ResolveDefaultAgentID() + } else { + agentID = config.NormalizeAgentID(agentID) + } + + sessionKey := sessions.BuildCronSessionKey(agentID, job.ID) + channel := job.Payload.Channel + if channel == "" { + channel = "cron" + } + + // Schedule through cron lane — scheduler handles agent resolution and concurrency + outCh := sched.Schedule(context.Background(), scheduler.LaneCron, agent.RunRequest{ + SessionKey: sessionKey, + Message: job.Payload.Message, + Channel: channel, + ChatID: job.Payload.To, + UserID: job.UserID, + RunID: fmt.Sprintf("cron:%s", job.ID), + Stream: false, + TraceName: fmt.Sprintf("Cron [%s] - %s", job.Name, agentID), + TraceTags: []string{"cron"}, + }) + + // Block until the scheduled run completes + outcome := <-outCh + if outcome.Err != nil { + return nil, outcome.Err + } + + result := outcome.Result + + // If job wants delivery to a channel, publish outbound + if job.Payload.Deliver && job.Payload.Channel != "" && job.Payload.To != "" { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: job.Payload.Channel, + ChatID: job.Payload.To, + Content: result.Content, + }) + } + + cronResult := &store.CronJobResult{ + Content: result.Content, + } + if result.Usage != nil { + cronResult.InputTokens = result.Usage.PromptTokens + cronResult.OutputTokens = result.Usage.CompletionTokens + } + + return cronResult, nil + } +} diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go index c1b01c54..b13fece2 100644 --- a/internal/channels/feishu/bot.go +++ b/internal/channels/feishu/bot.go @@ -2,10 +2,8 @@ package feishu import ( "context" - "encoding/json" "fmt" "log/slog" - "strings" "time" "github.com/nextlevelbuilder/goclaw/internal/channels" @@ -155,323 +153,3 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { c.groupHistory.Clear(chatID) } } - -// --- Parse --- - -func (c *Channel) parseMessageEvent(event *MessageEvent) *messageContext { - msg := &event.Event.Message - sender := &event.Event.Sender - - chatID := msg.ChatID - messageID := msg.MessageID - chatType := msg.ChatType - contentType := msg.MessageType - rootID := msg.RootID - parentID := msg.ParentID - - senderID := "" - if sender != nil { - senderID = sender.SenderID.OpenID - } - - // Parse content - content := parseMessageContent(msg.Content, contentType) - - // Parse mentions - var mentions []mentionInfo - mentionedBot := false - for _, m := range msg.Mentions { - mi := mentionInfo{ - Key: m.Key, - OpenID: m.ID.OpenID, - Name: m.Name, - } - mentions = append(mentions, mi) - - // Check if bot is mentioned - if c.botOpenID != "" && mi.OpenID == c.botOpenID { - mentionedBot = true - } - } - - // Strip bot mention from content - if mentionedBot && c.botOpenID != "" { - content = stripBotMention(content, mentions, c.botOpenID) - } - - return &messageContext{ - ChatID: chatID, - MessageID: messageID, - SenderID: senderID, - ChatType: chatType, - Content: content, - ContentType: contentType, - MentionedBot: mentionedBot, - RootID: rootID, - ParentID: parentID, - Mentions: mentions, - } -} - -// --- Content parsing --- - -func parseMessageContent(rawContent, messageType string) string { - if rawContent == "" { - return "" - } - - switch messageType { - case "text": - var textMsg struct { - Text string `json:"text"` - } - if err := json.Unmarshal([]byte(rawContent), &textMsg); err == nil { - return textMsg.Text - } - return rawContent - - case "post": - return parsePostContent(rawContent) - - case "image": - return "[image]" - - case "file": - var fileMsg struct { - FileName string `json:"file_name"` - } - if err := json.Unmarshal([]byte(rawContent), &fileMsg); err == nil { - return fmt.Sprintf("[file: %s]", fileMsg.FileName) - } - return "[file]" - - default: - return fmt.Sprintf("[%s message]", messageType) - } -} - -func parsePostContent(rawContent string) string { - var post map[string]interface{} - if err := json.Unmarshal([]byte(rawContent), &post); err != nil { - return rawContent - } - - var langContent interface{} - for _, lang := range []string{"zh_cn", "en_us"} { - if lc, ok := post[lang]; ok { - langContent = lc - break - } - } - if langContent == nil { - for _, v := range post { - langContent = v - break - } - } - if langContent == nil { - return rawContent - } - - langMap, ok := langContent.(map[string]interface{}) - if !ok { - return rawContent - } - - contentArr, ok := langMap["content"].([]interface{}) - if !ok { - return rawContent - } - - var textParts []string - for _, para := range contentArr { - paraArr, ok := para.([]interface{}) - if !ok { - continue - } - var lineParts []string - for _, elem := range paraArr { - elemMap, ok := elem.(map[string]interface{}) - if !ok { - continue - } - tag, _ := elemMap["tag"].(string) - switch tag { - case "text": - if t, ok := elemMap["text"].(string); ok { - lineParts = append(lineParts, t) - } - case "md": - if t, ok := elemMap["text"].(string); ok { - lineParts = append(lineParts, t) - } - case "at": - if name, ok := elemMap["user_name"].(string); ok { - lineParts = append(lineParts, "@"+name) - } - case "a": - if href, ok := elemMap["href"].(string); ok { - text, _ := elemMap["text"].(string) - if text != "" { - lineParts = append(lineParts, fmt.Sprintf("[%s](%s)", text, href)) - } else { - lineParts = append(lineParts, href) - } - } - case "img": - lineParts = append(lineParts, "[image]") - } - } - if len(lineParts) > 0 { - textParts = append(textParts, strings.Join(lineParts, "")) - } - } - - return strings.Join(textParts, "\n") -} - -func stripBotMention(text string, mentions []mentionInfo, botOpenID string) string { - for _, m := range mentions { - if m.OpenID == botOpenID && m.Key != "" { - text = strings.ReplaceAll(text, m.Key, "") - } - } - return strings.TrimSpace(text) -} - -// --- Sender name resolution --- - -func (c *Channel) resolveSenderName(ctx context.Context, openID string) string { - if openID == "" { - return "" - } - - // Check cache - if entry, ok := c.senderCache.Load(openID); ok { - e := entry.(*senderCacheEntry) - if time.Now().Before(e.expiresAt) { - return e.name - } - c.senderCache.Delete(openID) - } - - // Fetch from API - name := c.fetchSenderName(ctx, openID) - if name != "" { - c.senderCache.Store(openID, &senderCacheEntry{ - name: name, - expiresAt: time.Now().Add(senderCacheTTL), - }) - } - return name -} - -func (c *Channel) fetchSenderName(ctx context.Context, openID string) string { - name, err := c.client.GetUser(ctx, openID, "open_id") - if err != nil { - slog.Debug("feishu fetch sender name failed", "open_id", openID, "error", err) - return "" - } - return name -} - -// --- Policy checks --- - -func (c *Channel) checkGroupPolicy(senderID string) bool { - groupPolicy := c.cfg.GroupPolicy - if groupPolicy == "" { - groupPolicy = "open" - } - - switch groupPolicy { - case "disabled": - return false - case "allowlist": - if c.IsAllowed(senderID) { - return true - } - for _, allowed := range c.groupAllowList { - if senderID == allowed || strings.TrimPrefix(allowed, "@") == senderID { - return true - } - } - return false - default: // "open" - return true - } -} - -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { - dmPolicy := c.cfg.DMPolicy - if dmPolicy == "" { - dmPolicy = "pairing" - } - - switch dmPolicy { - case "disabled": - slog.Debug("feishu DM rejected: disabled", "sender_id", senderID) - return false - case "open": - return true - case "allowlist": - if !c.IsAllowed(senderID) { - slog.Debug("feishu DM rejected by allowlist", "sender_id", senderID) - return false - } - return true - default: // "pairing" - paired := false - if c.pairingService != nil { - paired = c.pairingService.IsPaired(senderID, c.Name()) - } - inAllowList := c.HasAllowList() && c.IsAllowed(senderID) - - if paired || inAllowList { - return true - } - - c.sendPairingReply(senderID, chatID) - return false - } -} - -func (c *Channel) sendPairingReply(senderID, chatID string) { - if c.pairingService == nil { - return - } - - // Debounce - if lastSent, ok := c.pairingDebounce.Load(senderID); ok { - if time.Since(lastSent.(time.Time)) < pairingDebounceTime { - return - } - } - - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default") - if err != nil { - slog.Debug("feishu pairing request failed", "sender_id", senderID, "error", err) - return - } - - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour Feishu open_id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) - - receiveIDType := resolveReceiveIDType(chatID) - if err := c.sendText(context.Background(), chatID, receiveIDType, replyText); err != nil { - slog.Warn("failed to send feishu pairing reply", "error", err) - } else { - c.pairingDebounce.Store(senderID, time.Now()) - slog.Info("feishu pairing reply sent", "sender_id", senderID, "code", code) - } -} - -// --- Helpers --- - -func safeStr(s *string) string { - if s == nil { - return "" - } - return *s -} diff --git a/internal/channels/feishu/bot_parse.go b/internal/channels/feishu/bot_parse.go new file mode 100644 index 00000000..ba8fceb2 --- /dev/null +++ b/internal/channels/feishu/bot_parse.go @@ -0,0 +1,197 @@ +package feishu + +import ( + "encoding/json" + "fmt" + "strings" +) + +func (c *Channel) parseMessageEvent(event *MessageEvent) *messageContext { + msg := &event.Event.Message + sender := &event.Event.Sender + + chatID := msg.ChatID + messageID := msg.MessageID + chatType := msg.ChatType + contentType := msg.MessageType + rootID := msg.RootID + parentID := msg.ParentID + + senderID := "" + if sender != nil { + senderID = sender.SenderID.OpenID + } + + // Parse content + content := parseMessageContent(msg.Content, contentType) + + // Parse mentions + var mentions []mentionInfo + mentionedBot := false + for _, m := range msg.Mentions { + mi := mentionInfo{ + Key: m.Key, + OpenID: m.ID.OpenID, + Name: m.Name, + } + mentions = append(mentions, mi) + + // Check if bot is mentioned + if c.botOpenID != "" && mi.OpenID == c.botOpenID { + mentionedBot = true + } + } + + // Strip bot mention from content + if mentionedBot && c.botOpenID != "" { + content = stripBotMention(content, mentions, c.botOpenID) + } + + return &messageContext{ + ChatID: chatID, + MessageID: messageID, + SenderID: senderID, + ChatType: chatType, + Content: content, + ContentType: contentType, + MentionedBot: mentionedBot, + RootID: rootID, + ParentID: parentID, + Mentions: mentions, + } +} + +// --- Content parsing --- + +func parseMessageContent(rawContent, messageType string) string { + if rawContent == "" { + return "" + } + + switch messageType { + case "text": + var textMsg struct { + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(rawContent), &textMsg); err == nil { + return textMsg.Text + } + return rawContent + + case "post": + return parsePostContent(rawContent) + + case "image": + return "[image]" + + case "file": + var fileMsg struct { + FileName string `json:"file_name"` + } + if err := json.Unmarshal([]byte(rawContent), &fileMsg); err == nil { + return fmt.Sprintf("[file: %s]", fileMsg.FileName) + } + return "[file]" + + default: + return fmt.Sprintf("[%s message]", messageType) + } +} + +func parsePostContent(rawContent string) string { + var post map[string]interface{} + if err := json.Unmarshal([]byte(rawContent), &post); err != nil { + return rawContent + } + + var langContent interface{} + for _, lang := range []string{"zh_cn", "en_us"} { + if lc, ok := post[lang]; ok { + langContent = lc + break + } + } + if langContent == nil { + for _, v := range post { + langContent = v + break + } + } + if langContent == nil { + return rawContent + } + + langMap, ok := langContent.(map[string]interface{}) + if !ok { + return rawContent + } + + contentArr, ok := langMap["content"].([]interface{}) + if !ok { + return rawContent + } + + var textParts []string + for _, para := range contentArr { + paraArr, ok := para.([]interface{}) + if !ok { + continue + } + var lineParts []string + for _, elem := range paraArr { + elemMap, ok := elem.(map[string]interface{}) + if !ok { + continue + } + tag, _ := elemMap["tag"].(string) + switch tag { + case "text": + if t, ok := elemMap["text"].(string); ok { + lineParts = append(lineParts, t) + } + case "md": + if t, ok := elemMap["text"].(string); ok { + lineParts = append(lineParts, t) + } + case "at": + if name, ok := elemMap["user_name"].(string); ok { + lineParts = append(lineParts, "@"+name) + } + case "a": + if href, ok := elemMap["href"].(string); ok { + text, _ := elemMap["text"].(string) + if text != "" { + lineParts = append(lineParts, fmt.Sprintf("[%s](%s)", text, href)) + } else { + lineParts = append(lineParts, href) + } + } + case "img": + lineParts = append(lineParts, "[image]") + } + } + if len(lineParts) > 0 { + textParts = append(textParts, strings.Join(lineParts, "")) + } + } + + return strings.Join(textParts, "\n") +} + +func stripBotMention(text string, mentions []mentionInfo, botOpenID string) string { + for _, m := range mentions { + if m.OpenID == botOpenID && m.Key != "" { + text = strings.ReplaceAll(text, m.Key, "") + } + } + return strings.TrimSpace(text) +} + +// --- Helpers --- + +func safeStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/channels/feishu/bot_policy.go b/internal/channels/feishu/bot_policy.go new file mode 100644 index 00000000..19d4b1ce --- /dev/null +++ b/internal/channels/feishu/bot_policy.go @@ -0,0 +1,137 @@ +package feishu + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" +) + +// --- Sender name resolution --- + +func (c *Channel) resolveSenderName(ctx context.Context, openID string) string { + if openID == "" { + return "" + } + + // Check cache + if entry, ok := c.senderCache.Load(openID); ok { + e := entry.(*senderCacheEntry) + if time.Now().Before(e.expiresAt) { + return e.name + } + c.senderCache.Delete(openID) + } + + // Fetch from API + name := c.fetchSenderName(ctx, openID) + if name != "" { + c.senderCache.Store(openID, &senderCacheEntry{ + name: name, + expiresAt: time.Now().Add(senderCacheTTL), + }) + } + return name +} + +func (c *Channel) fetchSenderName(ctx context.Context, openID string) string { + name, err := c.client.GetUser(ctx, openID, "open_id") + if err != nil { + slog.Debug("feishu fetch sender name failed", "open_id", openID, "error", err) + return "" + } + return name +} + +// --- Policy checks --- + +func (c *Channel) checkGroupPolicy(senderID string) bool { + groupPolicy := c.cfg.GroupPolicy + if groupPolicy == "" { + groupPolicy = "open" + } + + switch groupPolicy { + case "disabled": + return false + case "allowlist": + if c.IsAllowed(senderID) { + return true + } + for _, allowed := range c.groupAllowList { + if senderID == allowed || strings.TrimPrefix(allowed, "@") == senderID { + return true + } + } + return false + default: // "open" + return true + } +} + +func (c *Channel) checkDMPolicy(senderID, chatID string) bool { + dmPolicy := c.cfg.DMPolicy + if dmPolicy == "" { + dmPolicy = "pairing" + } + + switch dmPolicy { + case "disabled": + slog.Debug("feishu DM rejected: disabled", "sender_id", senderID) + return false + case "open": + return true + case "allowlist": + if !c.IsAllowed(senderID) { + slog.Debug("feishu DM rejected by allowlist", "sender_id", senderID) + return false + } + return true + default: // "pairing" + paired := false + if c.pairingService != nil { + paired = c.pairingService.IsPaired(senderID, c.Name()) + } + inAllowList := c.HasAllowList() && c.IsAllowed(senderID) + + if paired || inAllowList { + return true + } + + c.sendPairingReply(senderID, chatID) + return false + } +} + +func (c *Channel) sendPairingReply(senderID, chatID string) { + if c.pairingService == nil { + return + } + + // Debounce + if lastSent, ok := c.pairingDebounce.Load(senderID); ok { + if time.Since(lastSent.(time.Time)) < pairingDebounceTime { + return + } + } + + code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default") + if err != nil { + slog.Debug("feishu pairing request failed", "sender_id", senderID, "error", err) + return + } + + replyText := fmt.Sprintf( + "GoClaw: access not configured.\n\nYour Feishu open_id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", + senderID, code, code, + ) + + receiveIDType := resolveReceiveIDType(chatID) + if err := c.sendText(context.Background(), chatID, receiveIDType, replyText); err != nil { + slog.Warn("failed to send feishu pairing reply", "error", err) + } else { + c.pairingDebounce.Store(senderID, time.Now()) + slog.Info("feishu pairing reply sent", "sender_id", senderID, "code", code) + } +} diff --git a/internal/channels/feishu/larkclient.go b/internal/channels/feishu/larkclient.go index 8f7a0b36..f17805ad 100644 --- a/internal/channels/feishu/larkclient.go +++ b/internal/channels/feishu/larkclient.go @@ -6,11 +6,9 @@ import ( "encoding/json" "fmt" "io" - "log/slog" "mime" "mime/multipart" "net/http" - "strconv" "sync" "time" ) @@ -252,178 +250,3 @@ func (c *LarkClient) doMultipart(ctx context.Context, path string, fields map[st } return &result, nil } - -// --- IM API: Messages --- - -type SendMessageResp struct { - MessageID string `json:"message_id"` -} - -func (c *LarkClient) SendMessage(ctx context.Context, receiveIDType, receiveID, msgType, content string) (*SendMessageResp, error) { - path := "/open-apis/im/v1/messages?receive_id_type=" + receiveIDType - body := map[string]string{ - "receive_id": receiveID, - "msg_type": msgType, - "content": content, - } - resp, err := c.doJSON(ctx, "POST", path, body) - if err != nil { - return nil, err - } - if resp.Code != 0 { - return nil, fmt.Errorf("send message: code=%d msg=%s", resp.Code, resp.Msg) - } - var data SendMessageResp - json.Unmarshal(resp.Data, &data) - return &data, nil -} - -// --- IM API: Images --- - -func (c *LarkClient) DownloadImage(ctx context.Context, imageKey string) ([]byte, error) { - path := "/open-apis/im/v1/images/" + imageKey - data, _, err := c.doDownload(ctx, path) - return data, err -} - -func (c *LarkClient) UploadImage(ctx context.Context, data io.Reader) (string, error) { - resp, err := c.doMultipart(ctx, "/open-apis/im/v1/images", - map[string]string{"image_type": "message"}, - "image", data, "image.png") - if err != nil { - return "", err - } - if resp.Code != 0 { - return "", fmt.Errorf("upload image: code=%d msg=%s", resp.Code, resp.Msg) - } - var result struct { - ImageKey string `json:"image_key"` - } - json.Unmarshal(resp.Data, &result) - return result.ImageKey, nil -} - -// --- IM API: Files --- - -func (c *LarkClient) UploadFile(ctx context.Context, data io.Reader, fileName, fileType string, durationMs int) (string, error) { - fields := map[string]string{ - "file_type": fileType, - "file_name": fileName, - } - if durationMs > 0 { - fields["duration"] = strconv.Itoa(durationMs) - } - resp, err := c.doMultipart(ctx, "/open-apis/im/v1/files", fields, "file", data, fileName) - if err != nil { - return "", err - } - if resp.Code != 0 { - return "", fmt.Errorf("upload file: code=%d msg=%s", resp.Code, resp.Msg) - } - var result struct { - FileKey string `json:"file_key"` - } - json.Unmarshal(resp.Data, &result) - return result.FileKey, nil -} - -// --- IM API: Message Resources --- - -func (c *LarkClient) DownloadMessageResource(ctx context.Context, messageID, fileKey, resourceType string) ([]byte, string, error) { - path := fmt.Sprintf("/open-apis/im/v1/messages/%s/resources/%s?type=%s", messageID, fileKey, resourceType) - return c.doDownload(ctx, path) -} - -// --- CardKit API --- - -func (c *LarkClient) CreateCard(ctx context.Context, cardType, data string) (string, error) { - resp, err := c.doJSON(ctx, "POST", "/open-apis/cardkit/v1/cards", map[string]string{ - "type": cardType, - "data": data, - }) - if err != nil { - return "", err - } - if resp.Code != 0 { - return "", fmt.Errorf("create card: code=%d msg=%s", resp.Code, resp.Msg) - } - var result struct { - CardID string `json:"card_id"` - } - json.Unmarshal(resp.Data, &result) - return result.CardID, nil -} - -func (c *LarkClient) UpdateCardSettings(ctx context.Context, cardID, settings string, seq int, uuid string) error { - path := "/open-apis/cardkit/v1/cards/" + cardID - resp, err := c.doJSON(ctx, "PATCH", path, map[string]interface{}{ - "settings": settings, - "sequence": seq, - "uuid": uuid, - }) - if err != nil { - return err - } - if resp.Code != 0 { - return fmt.Errorf("update card settings: code=%d msg=%s", resp.Code, resp.Msg) - } - return nil -} - -func (c *LarkClient) UpdateCardElement(ctx context.Context, cardID, elementID, content string, seq int, uuid string) error { - path := fmt.Sprintf("/open-apis/cardkit/v1/cards/%s/elements/%s", cardID, elementID) - resp, err := c.doJSON(ctx, "PATCH", path, map[string]interface{}{ - "content": content, - "sequence": seq, - "uuid": uuid, - }) - if err != nil { - return err - } - if resp.Code != 0 { - slog.Debug("lark update card element failed", "code", resp.Code, "msg", resp.Msg) - return fmt.Errorf("update card element: code=%d msg=%s", resp.Code, resp.Msg) - } - return nil -} - -// --- Bot API --- - -// GetBotInfo fetches the bot's identity from /open-apis/bot/v3/info. -// Returns the bot's open_id which is needed for mention detection in groups. -func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) { - resp, err := c.doJSON(ctx, "GET", "/open-apis/bot/v3/info", nil) - if err != nil { - return "", err - } - if resp.Code != 0 { - return "", fmt.Errorf("get bot info: code=%d msg=%s", resp.Code, resp.Msg) - } - var result struct { - Bot struct { - OpenID string `json:"open_id"` - } `json:"bot"` - } - json.Unmarshal(resp.Data, &result) - return result.Bot.OpenID, nil -} - -// --- Contact API --- - -func (c *LarkClient) GetUser(ctx context.Context, userID, userIDType string) (string, error) { - path := fmt.Sprintf("/open-apis/contact/v3/users/%s?user_id_type=%s", userID, userIDType) - resp, err := c.doJSON(ctx, "GET", path, nil) - if err != nil { - return "", err - } - if resp.Code != 0 { - return "", fmt.Errorf("get user: code=%d msg=%s", resp.Code, resp.Msg) - } - var result struct { - User struct { - Name string `json:"name"` - } `json:"user"` - } - json.Unmarshal(resp.Data, &result) - return result.User.Name, nil -} diff --git a/internal/channels/feishu/larkclient_messaging.go b/internal/channels/feishu/larkclient_messaging.go new file mode 100644 index 00000000..890bf5b5 --- /dev/null +++ b/internal/channels/feishu/larkclient_messaging.go @@ -0,0 +1,185 @@ +package feishu + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "strconv" +) + +// --- IM API: Messages --- + +type SendMessageResp struct { + MessageID string `json:"message_id"` +} + +func (c *LarkClient) SendMessage(ctx context.Context, receiveIDType, receiveID, msgType, content string) (*SendMessageResp, error) { + path := "/open-apis/im/v1/messages?receive_id_type=" + receiveIDType + body := map[string]string{ + "receive_id": receiveID, + "msg_type": msgType, + "content": content, + } + resp, err := c.doJSON(ctx, "POST", path, body) + if err != nil { + return nil, err + } + if resp.Code != 0 { + return nil, fmt.Errorf("send message: code=%d msg=%s", resp.Code, resp.Msg) + } + var data SendMessageResp + json.Unmarshal(resp.Data, &data) + return &data, nil +} + +// --- IM API: Images --- + +func (c *LarkClient) DownloadImage(ctx context.Context, imageKey string) ([]byte, error) { + path := "/open-apis/im/v1/images/" + imageKey + data, _, err := c.doDownload(ctx, path) + return data, err +} + +func (c *LarkClient) UploadImage(ctx context.Context, data io.Reader) (string, error) { + resp, err := c.doMultipart(ctx, "/open-apis/im/v1/images", + map[string]string{"image_type": "message"}, + "image", data, "image.png") + if err != nil { + return "", err + } + if resp.Code != 0 { + return "", fmt.Errorf("upload image: code=%d msg=%s", resp.Code, resp.Msg) + } + var result struct { + ImageKey string `json:"image_key"` + } + json.Unmarshal(resp.Data, &result) + return result.ImageKey, nil +} + +// --- IM API: Files --- + +func (c *LarkClient) UploadFile(ctx context.Context, data io.Reader, fileName, fileType string, durationMs int) (string, error) { + fields := map[string]string{ + "file_type": fileType, + "file_name": fileName, + } + if durationMs > 0 { + fields["duration"] = strconv.Itoa(durationMs) + } + resp, err := c.doMultipart(ctx, "/open-apis/im/v1/files", fields, "file", data, fileName) + if err != nil { + return "", err + } + if resp.Code != 0 { + return "", fmt.Errorf("upload file: code=%d msg=%s", resp.Code, resp.Msg) + } + var result struct { + FileKey string `json:"file_key"` + } + json.Unmarshal(resp.Data, &result) + return result.FileKey, nil +} + +// --- IM API: Message Resources --- + +func (c *LarkClient) DownloadMessageResource(ctx context.Context, messageID, fileKey, resourceType string) ([]byte, string, error) { + path := fmt.Sprintf("/open-apis/im/v1/messages/%s/resources/%s?type=%s", messageID, fileKey, resourceType) + return c.doDownload(ctx, path) +} + +// --- CardKit API --- + +func (c *LarkClient) CreateCard(ctx context.Context, cardType, data string) (string, error) { + resp, err := c.doJSON(ctx, "POST", "/open-apis/cardkit/v1/cards", map[string]string{ + "type": cardType, + "data": data, + }) + if err != nil { + return "", err + } + if resp.Code != 0 { + return "", fmt.Errorf("create card: code=%d msg=%s", resp.Code, resp.Msg) + } + var result struct { + CardID string `json:"card_id"` + } + json.Unmarshal(resp.Data, &result) + return result.CardID, nil +} + +func (c *LarkClient) UpdateCardSettings(ctx context.Context, cardID, settings string, seq int, uuid string) error { + path := "/open-apis/cardkit/v1/cards/" + cardID + resp, err := c.doJSON(ctx, "PATCH", path, map[string]interface{}{ + "settings": settings, + "sequence": seq, + "uuid": uuid, + }) + if err != nil { + return err + } + if resp.Code != 0 { + return fmt.Errorf("update card settings: code=%d msg=%s", resp.Code, resp.Msg) + } + return nil +} + +func (c *LarkClient) UpdateCardElement(ctx context.Context, cardID, elementID, content string, seq int, uuid string) error { + path := fmt.Sprintf("/open-apis/cardkit/v1/cards/%s/elements/%s", cardID, elementID) + resp, err := c.doJSON(ctx, "PATCH", path, map[string]interface{}{ + "content": content, + "sequence": seq, + "uuid": uuid, + }) + if err != nil { + return err + } + if resp.Code != 0 { + slog.Debug("lark update card element failed", "code", resp.Code, "msg", resp.Msg) + return fmt.Errorf("update card element: code=%d msg=%s", resp.Code, resp.Msg) + } + return nil +} + +// --- Bot API --- + +// GetBotInfo fetches the bot's identity from /open-apis/bot/v3/info. +// Returns the bot's open_id which is needed for mention detection in groups. +func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) { + resp, err := c.doJSON(ctx, "GET", "/open-apis/bot/v3/info", nil) + if err != nil { + return "", err + } + if resp.Code != 0 { + return "", fmt.Errorf("get bot info: code=%d msg=%s", resp.Code, resp.Msg) + } + var result struct { + Bot struct { + OpenID string `json:"open_id"` + } `json:"bot"` + } + json.Unmarshal(resp.Data, &result) + return result.Bot.OpenID, nil +} + +// --- Contact API --- + +func (c *LarkClient) GetUser(ctx context.Context, userID, userIDType string) (string, error) { + path := fmt.Sprintf("/open-apis/contact/v3/users/%s?user_id_type=%s", userID, userIDType) + resp, err := c.doJSON(ctx, "GET", path, nil) + if err != nil { + return "", err + } + if resp.Code != 0 { + return "", fmt.Errorf("get user: code=%d msg=%s", resp.Code, resp.Msg) + } + var result struct { + User struct { + Name string `json:"name"` + } `json:"user"` + } + json.Unmarshal(resp.Data, &result) + return result.User.Name, nil +} diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 44ce8152..1c5fa904 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -4,14 +4,11 @@ import ( "context" "fmt" "log/slog" - "strings" "sync" "sync/atomic" "time" mcpclient "github.com/mark3labs/mcp-go/client" - "github.com/mark3labs/mcp-go/client/transport" - mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" @@ -198,348 +195,3 @@ func (m *Manager) ServerStatus() []ServerStatus { } return statuses } - -// ToolNames returns all registered MCP tool names. -func (m *Manager) ToolNames() []string { - m.mu.RLock() - defer m.mu.RUnlock() - - var names []string - for _, ss := range m.servers { - names = append(names, ss.toolNames...) - } - return names -} - -// connectServer creates a client, initializes the connection, discovers tools, and registers them. -func (m *Manager) connectServer(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error { - client, err := createClient(transportType, command, args, env, url, headers) - if err != nil { - return fmt.Errorf("create client: %w", err) - } - - // Start transport (SSE/streamable-http need explicit Start; stdio auto-starts) - if transportType != "stdio" { - if err := client.Start(ctx); err != nil { - _ = client.Close() - return fmt.Errorf("start transport: %w", err) - } - } - - // Initialize MCP handshake - initReq := mcpgo.InitializeRequest{} - initReq.Params.ProtocolVersion = mcpgo.LATEST_PROTOCOL_VERSION - initReq.Params.ClientInfo = mcpgo.Implementation{ - Name: "openclaw-go", - Version: "1.0.0", - } - - if _, err := client.Initialize(ctx, initReq); err != nil { - _ = client.Close() - return fmt.Errorf("initialize: %w", err) - } - - // Discover tools - toolsResult, err := client.ListTools(ctx, mcpgo.ListToolsRequest{}) - if err != nil { - _ = client.Close() - return fmt.Errorf("list tools: %w", err) - } - - if timeoutSec <= 0 { - timeoutSec = 60 - } - - ss := &serverState{ - name: name, - transport: transportType, - client: client, - timeoutSec: timeoutSec, - } - ss.connected.Store(true) - - // Register tools - var registeredNames []string - for _, mcpTool := range toolsResult.Tools { - bt := NewBridgeTool(name, mcpTool, client, toolPrefix, timeoutSec, &ss.connected) - - // Check for name collision with existing tools - if _, exists := m.registry.Get(bt.Name()); exists { - slog.Warn("mcp.tool.name_collision", - "server", name, - "tool", bt.Name(), - "action", "skipped", - ) - continue - } - - m.registry.Register(bt) - registeredNames = append(registeredNames, bt.Name()) - } - ss.toolNames = registeredNames - - // Register dynamic tool groups for policy filtering - if len(registeredNames) > 0 { - tools.RegisterToolGroup("mcp:"+name, registeredNames) - m.updateMCPGroup() - } - - // Start health monitoring - hctx, hcancel := context.WithCancel(context.Background()) - ss.cancel = hcancel - go m.healthLoop(hctx, ss) - - m.mu.Lock() - m.servers[name] = ss - m.mu.Unlock() - - slog.Info("mcp.server.connected", - "server", name, - "transport", transportType, - "tools", len(registeredNames), - ) - - return nil -} - -// createClient creates the appropriate MCP client based on transport type. -func createClient(transportType, command string, args []string, env map[string]string, url string, headers map[string]string) (*mcpclient.Client, error) { - switch transportType { - case "stdio": - envSlice := mapToEnvSlice(env) - return mcpclient.NewStdioMCPClient(command, envSlice, args...) - - case "sse": - var opts []transport.ClientOption - if len(headers) > 0 { - opts = append(opts, mcpclient.WithHeaders(headers)) - } - return mcpclient.NewSSEMCPClient(url, opts...) - - case "streamable-http": - var opts []transport.StreamableHTTPCOption - if len(headers) > 0 { - opts = append(opts, transport.WithHTTPHeaders(headers)) - } - return mcpclient.NewStreamableHttpClient(url, opts...) - - default: - return nil, fmt.Errorf("unsupported transport: %q", transportType) - } -} - -// healthLoop periodically pings the MCP server and attempts reconnection on failure. -func (m *Manager) healthLoop(ctx context.Context, ss *serverState) { - ticker := time.NewTicker(healthCheckInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if err := ss.client.Ping(ctx); err != nil { - // Servers that don't implement "ping" are still alive — treat as healthy. - if strings.Contains(strings.ToLower(err.Error()), "method not found") { - ss.connected.Store(true) - ss.mu.Lock() - ss.reconnAttempts = 0 - ss.lastErr = "" - ss.mu.Unlock() - continue - } - ss.connected.Store(false) - ss.mu.Lock() - ss.lastErr = err.Error() - ss.mu.Unlock() - - slog.Warn("mcp.server.health_failed", "server", ss.name, "error", err) - m.tryReconnect(ctx, ss) - } else { - ss.connected.Store(true) - ss.mu.Lock() - ss.reconnAttempts = 0 - ss.lastErr = "" - ss.mu.Unlock() - } - } - } -} - -// tryReconnect attempts to reconnect with exponential backoff. -func (m *Manager) tryReconnect(ctx context.Context, ss *serverState) { - ss.mu.Lock() - if ss.reconnAttempts >= maxReconnectAttempts { - ss.lastErr = fmt.Sprintf("max reconnect attempts (%d) reached", maxReconnectAttempts) - ss.mu.Unlock() - slog.Error("mcp.server.reconnect_exhausted", "server", ss.name) - return - } - ss.reconnAttempts++ - attempt := ss.reconnAttempts - ss.mu.Unlock() - - backoff := initialBackoff * time.Duration(1<<(attempt-1)) - if backoff > maxBackoff { - backoff = maxBackoff - } - - slog.Info("mcp.server.reconnecting", - "server", ss.name, - "attempt", attempt, - "backoff", backoff, - ) - - select { - case <-ctx.Done(): - return - case <-time.After(backoff): - } - - // Try to ping again — transport may have auto-reconnected - if err := ss.client.Ping(ctx); err == nil { - ss.connected.Store(true) - ss.mu.Lock() - ss.reconnAttempts = 0 - ss.lastErr = "" - ss.mu.Unlock() - slog.Info("mcp.server.reconnected", "server", ss.name) - } -} - -// updateMCPGroup rebuilds the "mcp" group with all MCP tool names across servers. -// Must be called with m.mu NOT held (it acquires RLock). -func (m *Manager) updateMCPGroup() { - allNames := m.ToolNames() - if len(allNames) > 0 { - tools.RegisterToolGroup("mcp", allNames) - } else { - tools.UnregisterToolGroup("mcp") - } -} - -// unregisterAllTools removes all MCP tools from the registry. -func (m *Manager) unregisterAllTools() { - m.mu.Lock() - defer m.mu.Unlock() - - for name, ss := range m.servers { - if ss.cancel != nil { - ss.cancel() - } - if ss.client != nil { - _ = ss.client.Close() - } - for _, toolName := range ss.toolNames { - m.registry.Unregister(toolName) - } - tools.UnregisterToolGroup("mcp:" + name) - slog.Debug("mcp.server.unregistered", "server", name, "tools", len(ss.toolNames)) - } - m.servers = make(map[string]*serverState) - tools.UnregisterToolGroup("mcp") -} - -// filterTools removes tools from the registry that don't match the allow/deny lists. -func (m *Manager) filterTools(serverName string, allow, deny []string) { - m.mu.Lock() - defer m.mu.Unlock() - - ss, ok := m.servers[serverName] - if !ok { - return - } - - allowSet := toSet(allow) - denySet := toSet(deny) - - var kept []string - for _, toolName := range ss.toolNames { - bt, ok := m.registry.Get(toolName) - if !ok { - continue - } - bridge, ok := bt.(*BridgeTool) - if !ok { - kept = append(kept, toolName) - continue - } - origName := bridge.OriginalName() - - // Deny takes priority - if _, denied := denySet[origName]; denied { - m.registry.Unregister(toolName) - continue - } - - // If allow list is set, only keep tools in the allow list - if len(allowSet) > 0 { - if _, allowed := allowSet[origName]; !allowed { - m.registry.Unregister(toolName) - continue - } - } - - kept = append(kept, toolName) - } - ss.toolNames = kept -} - -// --- helpers --- - -func mapToEnvSlice(env map[string]string) []string { - if len(env) == 0 { - return nil - } - s := make([]string, 0, len(env)) - for k, v := range env { - s = append(s, k+"="+v) - } - return s -} - -func toSet(items []string) map[string]struct{} { - if len(items) == 0 { - return nil - } - s := make(map[string]struct{}, len(items)) - for _, item := range items { - s[item] = struct{}{} - } - return s -} - -func joinErrors(errs []string) string { - result := "" - for i, e := range errs { - if i > 0 { - result += "; " - } - result += e - } - return result -} - -// jsonBytesToStringSlice converts JSONB []byte to []string. Returns nil on error. -func jsonBytesToStringSlice(data []byte) []string { - if len(data) == 0 { - return nil - } - var result []string - if err := jsonUnmarshal(data, &result); err != nil { - return nil - } - return result -} - -// jsonBytesToStringMap converts JSONB []byte to map[string]string. Returns nil on error. -func jsonBytesToStringMap(data []byte) map[string]string { - if len(data) == 0 { - return nil - } - var result map[string]string - if err := jsonUnmarshal(data, &result); err != nil { - return nil - } - return result -} diff --git a/internal/mcp/manager_connect.go b/internal/mcp/manager_connect.go new file mode 100644 index 00000000..b57bda1a --- /dev/null +++ b/internal/mcp/manager_connect.go @@ -0,0 +1,210 @@ +package mcp + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// connectServer creates a client, initializes the connection, discovers tools, and registers them. +func (m *Manager) connectServer(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error { + client, err := createClient(transportType, command, args, env, url, headers) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + + // Start transport (SSE/streamable-http need explicit Start; stdio auto-starts) + if transportType != "stdio" { + if err := client.Start(ctx); err != nil { + _ = client.Close() + return fmt.Errorf("start transport: %w", err) + } + } + + // Initialize MCP handshake + initReq := mcpgo.InitializeRequest{} + initReq.Params.ProtocolVersion = mcpgo.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcpgo.Implementation{ + Name: "openclaw-go", + Version: "1.0.0", + } + + if _, err := client.Initialize(ctx, initReq); err != nil { + _ = client.Close() + return fmt.Errorf("initialize: %w", err) + } + + // Discover tools + toolsResult, err := client.ListTools(ctx, mcpgo.ListToolsRequest{}) + if err != nil { + _ = client.Close() + return fmt.Errorf("list tools: %w", err) + } + + if timeoutSec <= 0 { + timeoutSec = 60 + } + + ss := &serverState{ + name: name, + transport: transportType, + client: client, + timeoutSec: timeoutSec, + } + ss.connected.Store(true) + + // Register tools + var registeredNames []string + for _, mcpTool := range toolsResult.Tools { + bt := NewBridgeTool(name, mcpTool, client, toolPrefix, timeoutSec, &ss.connected) + + // Check for name collision with existing tools + if _, exists := m.registry.Get(bt.Name()); exists { + slog.Warn("mcp.tool.name_collision", + "server", name, + "tool", bt.Name(), + "action", "skipped", + ) + continue + } + + m.registry.Register(bt) + registeredNames = append(registeredNames, bt.Name()) + } + ss.toolNames = registeredNames + + // Register dynamic tool groups for policy filtering + if len(registeredNames) > 0 { + tools.RegisterToolGroup("mcp:"+name, registeredNames) + m.updateMCPGroup() + } + + // Start health monitoring + hctx, hcancel := context.WithCancel(context.Background()) + ss.cancel = hcancel + go m.healthLoop(hctx, ss) + + m.mu.Lock() + m.servers[name] = ss + m.mu.Unlock() + + slog.Info("mcp.server.connected", + "server", name, + "transport", transportType, + "tools", len(registeredNames), + ) + + return nil +} + +// createClient creates the appropriate MCP client based on transport type. +func createClient(transportType, command string, args []string, env map[string]string, url string, headers map[string]string) (*mcpclient.Client, error) { + switch transportType { + case "stdio": + envSlice := mapToEnvSlice(env) + return mcpclient.NewStdioMCPClient(command, envSlice, args...) + + case "sse": + var opts []transport.ClientOption + if len(headers) > 0 { + opts = append(opts, mcpclient.WithHeaders(headers)) + } + return mcpclient.NewSSEMCPClient(url, opts...) + + case "streamable-http": + var opts []transport.StreamableHTTPCOption + if len(headers) > 0 { + opts = append(opts, transport.WithHTTPHeaders(headers)) + } + return mcpclient.NewStreamableHttpClient(url, opts...) + + default: + return nil, fmt.Errorf("unsupported transport: %q", transportType) + } +} + +// healthLoop periodically pings the MCP server and attempts reconnection on failure. +func (m *Manager) healthLoop(ctx context.Context, ss *serverState) { + ticker := time.NewTicker(healthCheckInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := ss.client.Ping(ctx); err != nil { + // Servers that don't implement "ping" are still alive — treat as healthy. + if strings.Contains(strings.ToLower(err.Error()), "method not found") { + ss.connected.Store(true) + ss.mu.Lock() + ss.reconnAttempts = 0 + ss.lastErr = "" + ss.mu.Unlock() + continue + } + ss.connected.Store(false) + ss.mu.Lock() + ss.lastErr = err.Error() + ss.mu.Unlock() + + slog.Warn("mcp.server.health_failed", "server", ss.name, "error", err) + m.tryReconnect(ctx, ss) + } else { + ss.connected.Store(true) + ss.mu.Lock() + ss.reconnAttempts = 0 + ss.lastErr = "" + ss.mu.Unlock() + } + } + } +} + +// tryReconnect attempts to reconnect with exponential backoff. +func (m *Manager) tryReconnect(ctx context.Context, ss *serverState) { + ss.mu.Lock() + if ss.reconnAttempts >= maxReconnectAttempts { + ss.lastErr = fmt.Sprintf("max reconnect attempts (%d) reached", maxReconnectAttempts) + ss.mu.Unlock() + slog.Error("mcp.server.reconnect_exhausted", "server", ss.name) + return + } + ss.reconnAttempts++ + attempt := ss.reconnAttempts + ss.mu.Unlock() + + backoff := initialBackoff * time.Duration(1<<(attempt-1)) + if backoff > maxBackoff { + backoff = maxBackoff + } + + slog.Info("mcp.server.reconnecting", + "server", ss.name, + "attempt", attempt, + "backoff", backoff, + ) + + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + + // Try to ping again — transport may have auto-reconnected + if err := ss.client.Ping(ctx); err == nil { + ss.connected.Store(true) + ss.mu.Lock() + ss.reconnAttempts = 0 + ss.lastErr = "" + ss.mu.Unlock() + slog.Info("mcp.server.reconnected", "server", ss.name) + } +} diff --git a/internal/mcp/manager_tools.go b/internal/mcp/manager_tools.go new file mode 100644 index 00000000..76ff917d --- /dev/null +++ b/internal/mcp/manager_tools.go @@ -0,0 +1,97 @@ +package mcp + +import ( + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// ToolNames returns all registered MCP tool names. +func (m *Manager) ToolNames() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + var names []string + for _, ss := range m.servers { + names = append(names, ss.toolNames...) + } + return names +} + +// updateMCPGroup rebuilds the "mcp" group with all MCP tool names across servers. +// Must be called with m.mu NOT held (it acquires RLock). +func (m *Manager) updateMCPGroup() { + allNames := m.ToolNames() + if len(allNames) > 0 { + tools.RegisterToolGroup("mcp", allNames) + } else { + tools.UnregisterToolGroup("mcp") + } +} + +// unregisterAllTools removes all MCP tools from the registry. +func (m *Manager) unregisterAllTools() { + m.mu.Lock() + defer m.mu.Unlock() + + for name, ss := range m.servers { + if ss.cancel != nil { + ss.cancel() + } + if ss.client != nil { + _ = ss.client.Close() + } + for _, toolName := range ss.toolNames { + m.registry.Unregister(toolName) + } + tools.UnregisterToolGroup("mcp:" + name) + slog.Debug("mcp.server.unregistered", "server", name, "tools", len(ss.toolNames)) + } + m.servers = make(map[string]*serverState) + tools.UnregisterToolGroup("mcp") +} + +// filterTools removes tools from the registry that don't match the allow/deny lists. +func (m *Manager) filterTools(serverName string, allow, deny []string) { + m.mu.Lock() + defer m.mu.Unlock() + + ss, ok := m.servers[serverName] + if !ok { + return + } + + allowSet := toSet(allow) + denySet := toSet(deny) + + var kept []string + for _, toolName := range ss.toolNames { + bt, ok := m.registry.Get(toolName) + if !ok { + continue + } + bridge, ok := bt.(*BridgeTool) + if !ok { + kept = append(kept, toolName) + continue + } + origName := bridge.OriginalName() + + // Deny takes priority + if _, denied := denySet[origName]; denied { + m.registry.Unregister(toolName) + continue + } + + // If allow list is set, only keep tools in the allow list + if len(allowSet) > 0 { + if _, allowed := allowSet[origName]; !allowed { + m.registry.Unregister(toolName) + continue + } + } + + kept = append(kept, toolName) + } + ss.toolNames = kept +} diff --git a/internal/mcp/manager_util.go b/internal/mcp/manager_util.go new file mode 100644 index 00000000..6dee2cc6 --- /dev/null +++ b/internal/mcp/manager_util.go @@ -0,0 +1,58 @@ +package mcp + +func mapToEnvSlice(env map[string]string) []string { + if len(env) == 0 { + return nil + } + s := make([]string, 0, len(env)) + for k, v := range env { + s = append(s, k+"="+v) + } + return s +} + +func toSet(items []string) map[string]struct{} { + if len(items) == 0 { + return nil + } + s := make(map[string]struct{}, len(items)) + for _, item := range items { + s[item] = struct{}{} + } + return s +} + +func joinErrors(errs []string) string { + result := "" + for i, e := range errs { + if i > 0 { + result += "; " + } + result += e + } + return result +} + +// jsonBytesToStringSlice converts JSONB []byte to []string. Returns nil on error. +func jsonBytesToStringSlice(data []byte) []string { + if len(data) == 0 { + return nil + } + var result []string + if err := jsonUnmarshal(data, &result); err != nil { + return nil + } + return result +} + +// jsonBytesToStringMap converts JSONB []byte to map[string]string. Returns nil on error. +func jsonBytesToStringMap(data []byte) map[string]string { + if len(data) == 0 { + return nil + } + var result map[string]string + if err := jsonUnmarshal(data, &result); err != nil { + return nil + } + return result +} diff --git a/internal/providers/anthropic.go b/internal/providers/anthropic.go index ea12dba9..ed02e371 100644 --- a/internal/providers/anthropic.go +++ b/internal/providers/anthropic.go @@ -1,7 +1,6 @@ package providers import ( - "bufio" "bytes" "context" "encoding/json" @@ -84,393 +83,6 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ChatRequest) (*ChatRes }) } -func (p *AnthropicProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { - model := req.Model - if model == "" { - model = p.defaultModel - } - - body := p.buildRequestBody(model, req, true) - - // Retry only the connection phase; once streaming starts, no retry. - respBody, err := RetryDo(ctx, p.retryConfig, func() (io.ReadCloser, error) { - return p.doRequest(ctx, body) - }) - if err != nil { - return nil, err - } - defer respBody.Close() - - result := &ChatResponse{FinishReason: "stop"} - // Accumulate raw JSON fragments for each tool call by index - toolCallJSON := make(map[int]string) - - // Track content blocks for RawAssistantContent (needed for thinking block passback) - var rawContentBlocks []json.RawMessage - var currentBlockType string - // Track thinking token count by accumulated chunk size - thinkingChars := 0 - - scanner := bufio.NewScanner(respBody) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 1MB max line for large thinking chunks - var currentEvent string - - for scanner.Scan() { - line := scanner.Text() - - // Track event type - if strings.HasPrefix(line, "event: ") { - currentEvent = strings.TrimPrefix(line, "event: ") - continue - } - - if !strings.HasPrefix(line, "data: ") { - continue - } - - data := strings.TrimPrefix(line, "data: ") - - switch currentEvent { - case "message_start": - var ev anthropicMessageStartEvent - if err := json.Unmarshal([]byte(data), &ev); err == nil { - if result.Usage == nil { - result.Usage = &Usage{} - } - if ev.Message.Usage.InputTokens > 0 { - result.Usage.PromptTokens = ev.Message.Usage.InputTokens - } - result.Usage.CacheCreationTokens = ev.Message.Usage.CacheCreationInputTokens - result.Usage.CacheReadTokens = ev.Message.Usage.CacheReadInputTokens - } - - case "content_block_start": - var ev anthropicContentBlockStartEvent - if err := json.Unmarshal([]byte(data), &ev); err == nil { - currentBlockType = ev.ContentBlock.Type - if ev.ContentBlock.Type == "tool_use" { - result.ToolCalls = append(result.ToolCalls, ToolCall{ - ID: ev.ContentBlock.ID, - Name: strings.TrimSpace(ev.ContentBlock.Name), - Arguments: make(map[string]interface{}), - }) - } - // Store raw content_block for later reconstruction - rawContentBlocks = append(rawContentBlocks, json.RawMessage(fmt.Sprintf(`{"type":"%s"`, ev.ContentBlock.Type))) - } - - case "content_block_delta": - var ev anthropicContentBlockDeltaEvent - if err := json.Unmarshal([]byte(data), &ev); err == nil { - switch ev.Delta.Type { - case "text_delta": - result.Content += ev.Delta.Text - if onChunk != nil { - onChunk(StreamChunk{Content: ev.Delta.Text}) - } - case "thinking_delta": - result.Thinking += ev.Delta.Thinking - thinkingChars += len(ev.Delta.Thinking) - if onChunk != nil { - onChunk(StreamChunk{Thinking: ev.Delta.Thinking}) - } - case "input_json_delta": - if len(result.ToolCalls) > 0 { - idx := len(result.ToolCalls) - 1 - toolCallJSON[idx] += ev.Delta.PartialJSON - } - case "signature_delta": - // Signature is captured in content_block_stop via raw block reconstruction - } - } - - case "content_block_stop": - // Reconstruct the complete content block for RawAssistantContent - if len(rawContentBlocks) > 0 { - idx := len(rawContentBlocks) - 1 - block := p.buildRawBlock(currentBlockType, result, toolCallJSON, idx) - if block != nil { - rawContentBlocks[idx] = block - } - } - currentBlockType = "" - - case "message_delta": - var ev anthropicMessageDeltaEvent - if err := json.Unmarshal([]byte(data), &ev); err == nil { - if ev.Delta.StopReason != "" { - switch ev.Delta.StopReason { - case "tool_use": - result.FinishReason = "tool_calls" - case "max_tokens": - result.FinishReason = "length" - default: - result.FinishReason = "stop" - } - } - if ev.Usage.OutputTokens > 0 { - if result.Usage == nil { - result.Usage = &Usage{} - } - result.Usage.CompletionTokens = ev.Usage.OutputTokens - } - } - - case "error": - var ev anthropicErrorEvent - if err := json.Unmarshal([]byte(data), &ev); err == nil { - return nil, fmt.Errorf("anthropic stream error: %s: %s", ev.Error.Type, ev.Error.Message) - } - - case "message_stop": - // Stream complete - } - } - - // Parse accumulated tool call JSON arguments - for i, rawJSON := range toolCallJSON { - if rawJSON != "" { - args := make(map[string]interface{}) - _ = json.Unmarshal([]byte(rawJSON), &args) - result.ToolCalls[i].Arguments = args - } - } - - if result.Usage != nil { - result.Usage.TotalTokens = result.Usage.PromptTokens + result.Usage.CompletionTokens - // Estimate thinking tokens from accumulated character count (~4 chars per token) - if thinkingChars > 0 { - result.Usage.ThinkingTokens = thinkingChars / 4 - } - } - - // Preserve raw content blocks for tool use passback - if len(rawContentBlocks) > 0 && len(result.ToolCalls) > 0 { - if b, err := json.Marshal(rawContentBlocks); err == nil { - result.RawAssistantContent = b - } - } - - if onChunk != nil { - onChunk(StreamChunk{Done: true}) - } - - return result, nil -} - -// buildRawBlock reconstructs a complete content block from streaming data. -// This is needed to preserve thinking blocks (with signatures) for tool use passback. -func (p *AnthropicProvider) buildRawBlock(blockType string, result *ChatResponse, toolCallJSON map[int]string, _ int) json.RawMessage { - switch blockType { - case "thinking": - block := map[string]interface{}{ - "type": "thinking", - "thinking": result.Thinking, - } - if b, err := json.Marshal(block); err == nil { - return b - } - case "text": - block := map[string]interface{}{ - "type": "text", - "text": result.Content, - } - if b, err := json.Marshal(block); err == nil { - return b - } - case "tool_use": - if len(result.ToolCalls) > 0 { - tc := result.ToolCalls[len(result.ToolCalls)-1] - // Parse accumulated JSON for this tool call - args := make(map[string]interface{}) - for i, rawJSON := range toolCallJSON { - if i == len(result.ToolCalls)-1 && rawJSON != "" { - _ = json.Unmarshal([]byte(rawJSON), &args) - } - } - block := map[string]interface{}{ - "type": "tool_use", - "id": tc.ID, - "name": tc.Name, - "input": args, - } - if b, err := json.Marshal(block); err == nil { - return b - } - } - case "redacted_thinking": - // Pass through as-is (we don't have the encrypted data in streaming) - block := map[string]interface{}{ - "type": "redacted_thinking", - } - if b, err := json.Marshal(block); err == nil { - return b - } - } - return nil -} - -func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stream bool) map[string]interface{} { - // Separate system messages and build conversation messages - var systemBlocks []map[string]interface{} - var messages []map[string]interface{} - - for _, msg := range req.Messages { - switch msg.Role { - case "system": - systemBlocks = append(systemBlocks, map[string]interface{}{ - "type": "text", - "text": msg.Content, - }) - - case "user": - if len(msg.Images) > 0 { - var blocks []map[string]interface{} - for _, img := range msg.Images { - blocks = append(blocks, map[string]interface{}{ - "type": "image", - "source": map[string]interface{}{ - "type": "base64", - "media_type": img.MimeType, - "data": img.Data, - }, - }) - } - if msg.Content != "" { - blocks = append(blocks, map[string]interface{}{ - "type": "text", - "text": msg.Content, - }) - } - messages = append(messages, map[string]interface{}{ - "role": "user", - "content": blocks, - }) - } else { - messages = append(messages, map[string]interface{}{ - "role": "user", - "content": msg.Content, - }) - } - - case "assistant": - // If we have raw content blocks (from Anthropic thinking), use them directly - // to preserve thinking blocks + signatures for tool use passback. - if msg.RawAssistantContent != nil { - var rawBlocks []json.RawMessage - if json.Unmarshal(msg.RawAssistantContent, &rawBlocks) == nil && len(rawBlocks) > 0 { - messages = append(messages, map[string]interface{}{ - "role": "assistant", - "content": rawBlocks, - }) - continue - } - } - - var blocks []map[string]interface{} - if msg.Content != "" { - blocks = append(blocks, map[string]interface{}{ - "type": "text", - "text": msg.Content, - }) - } - for _, tc := range msg.ToolCalls { - blocks = append(blocks, map[string]interface{}{ - "type": "tool_use", - "id": tc.ID, - "name": tc.Name, - "input": tc.Arguments, - }) - } - messages = append(messages, map[string]interface{}{ - "role": "assistant", - "content": blocks, - }) - - case "tool": - messages = append(messages, map[string]interface{}{ - "role": "user", - "content": []map[string]interface{}{ - { - "type": "tool_result", - "tool_use_id": msg.ToolCallID, - "content": msg.Content, - }, - }, - }) - } - } - - body := map[string]interface{}{ - "model": model, - "max_tokens": 4096, - "messages": messages, - "cache_control": map[string]interface{}{"type": "ephemeral"}, - } - - if stream { - body["stream"] = true - } - - if len(systemBlocks) > 0 { - body["system"] = systemBlocks - } - - // Translate tools to Anthropic format - if len(req.Tools) > 0 { - var tools []map[string]interface{} - for _, t := range req.Tools { - cleanedParams := CleanSchemaForProvider("anthropic", t.Function.Parameters) - tool := map[string]interface{}{ - "name": t.Function.Name, - "description": t.Function.Description, - "input_schema": cleanedParams, - } - tools = append(tools, tool) - } - body["tools"] = tools - } - - // Merge options - if v, ok := req.Options[OptMaxTokens]; ok { - body["max_tokens"] = v - } - if v, ok := req.Options[OptTemperature]; ok { - body["temperature"] = v - } - - // Enable extended thinking if thinking_level is set - if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" { - budget := anthropicThinkingBudget(level) - body["thinking"] = map[string]interface{}{ - "type": "enabled", - "budget_tokens": budget, - } - // Anthropic requires no temperature when thinking is enabled - delete(body, "temperature") - // Ensure max_tokens accommodates thinking budget + response - if maxTok, ok := body["max_tokens"].(int); !ok || maxTok < budget+4096 { - body["max_tokens"] = budget + 8192 - } - } - - return body -} - -// anthropicThinkingBudget maps a thinking level to a token budget. -func anthropicThinkingBudget(level string) int { - switch level { - case "low": - return 4096 - case "medium": - return 10000 - case "high": - return 32000 - default: - return 10000 - } -} - func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) { data, err := json.Marshal(body) if err != nil { diff --git a/internal/providers/anthropic_request.go b/internal/providers/anthropic_request.go new file mode 100644 index 00000000..62d7652a --- /dev/null +++ b/internal/providers/anthropic_request.go @@ -0,0 +1,216 @@ +package providers + +import "encoding/json" + +// buildRawBlock reconstructs a complete content block from streaming data. +// This is needed to preserve thinking blocks (with signatures) for tool use passback. +func (p *AnthropicProvider) buildRawBlock(blockType string, result *ChatResponse, toolCallJSON map[int]string, _ int) json.RawMessage { + switch blockType { + case "thinking": + block := map[string]interface{}{ + "type": "thinking", + "thinking": result.Thinking, + } + if b, err := json.Marshal(block); err == nil { + return b + } + case "text": + block := map[string]interface{}{ + "type": "text", + "text": result.Content, + } + if b, err := json.Marshal(block); err == nil { + return b + } + case "tool_use": + if len(result.ToolCalls) > 0 { + tc := result.ToolCalls[len(result.ToolCalls)-1] + // Parse accumulated JSON for this tool call + args := make(map[string]interface{}) + for i, rawJSON := range toolCallJSON { + if i == len(result.ToolCalls)-1 && rawJSON != "" { + _ = json.Unmarshal([]byte(rawJSON), &args) + } + } + block := map[string]interface{}{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Name, + "input": args, + } + if b, err := json.Marshal(block); err == nil { + return b + } + } + case "redacted_thinking": + // Pass through as-is (we don't have the encrypted data in streaming) + block := map[string]interface{}{ + "type": "redacted_thinking", + } + if b, err := json.Marshal(block); err == nil { + return b + } + } + return nil +} + +func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stream bool) map[string]interface{} { + // Separate system messages and build conversation messages + var systemBlocks []map[string]interface{} + var messages []map[string]interface{} + + for _, msg := range req.Messages { + switch msg.Role { + case "system": + systemBlocks = append(systemBlocks, map[string]interface{}{ + "type": "text", + "text": msg.Content, + }) + + case "user": + if len(msg.Images) > 0 { + var blocks []map[string]interface{} + for _, img := range msg.Images { + blocks = append(blocks, map[string]interface{}{ + "type": "image", + "source": map[string]interface{}{ + "type": "base64", + "media_type": img.MimeType, + "data": img.Data, + }, + }) + } + if msg.Content != "" { + blocks = append(blocks, map[string]interface{}{ + "type": "text", + "text": msg.Content, + }) + } + messages = append(messages, map[string]interface{}{ + "role": "user", + "content": blocks, + }) + } else { + messages = append(messages, map[string]interface{}{ + "role": "user", + "content": msg.Content, + }) + } + + case "assistant": + // If we have raw content blocks (from Anthropic thinking), use them directly + // to preserve thinking blocks + signatures for tool use passback. + if msg.RawAssistantContent != nil { + var rawBlocks []json.RawMessage + if json.Unmarshal(msg.RawAssistantContent, &rawBlocks) == nil && len(rawBlocks) > 0 { + messages = append(messages, map[string]interface{}{ + "role": "assistant", + "content": rawBlocks, + }) + continue + } + } + + var blocks []map[string]interface{} + if msg.Content != "" { + blocks = append(blocks, map[string]interface{}{ + "type": "text", + "text": msg.Content, + }) + } + for _, tc := range msg.ToolCalls { + blocks = append(blocks, map[string]interface{}{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Name, + "input": tc.Arguments, + }) + } + messages = append(messages, map[string]interface{}{ + "role": "assistant", + "content": blocks, + }) + + case "tool": + messages = append(messages, map[string]interface{}{ + "role": "user", + "content": []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + }, + }, + }) + } + } + + body := map[string]interface{}{ + "model": model, + "max_tokens": 4096, + "messages": messages, + "cache_control": map[string]interface{}{"type": "ephemeral"}, + } + + if stream { + body["stream"] = true + } + + if len(systemBlocks) > 0 { + body["system"] = systemBlocks + } + + // Translate tools to Anthropic format + if len(req.Tools) > 0 { + var tools []map[string]interface{} + for _, t := range req.Tools { + cleanedParams := CleanSchemaForProvider("anthropic", t.Function.Parameters) + tool := map[string]interface{}{ + "name": t.Function.Name, + "description": t.Function.Description, + "input_schema": cleanedParams, + } + tools = append(tools, tool) + } + body["tools"] = tools + } + + // Merge options + if v, ok := req.Options[OptMaxTokens]; ok { + body["max_tokens"] = v + } + if v, ok := req.Options[OptTemperature]; ok { + body["temperature"] = v + } + + // Enable extended thinking if thinking_level is set + if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" { + budget := anthropicThinkingBudget(level) + body["thinking"] = map[string]interface{}{ + "type": "enabled", + "budget_tokens": budget, + } + // Anthropic requires no temperature when thinking is enabled + delete(body, "temperature") + // Ensure max_tokens accommodates thinking budget + response + if maxTok, ok := body["max_tokens"].(int); !ok || maxTok < budget+4096 { + body["max_tokens"] = budget + 8192 + } + } + + return body +} + +// anthropicThinkingBudget maps a thinking level to a token budget. +func anthropicThinkingBudget(level string) int { + switch level { + case "low": + return 4096 + case "medium": + return 10000 + case "high": + return 32000 + default: + return 10000 + } +} diff --git a/internal/providers/anthropic_stream.go b/internal/providers/anthropic_stream.go new file mode 100644 index 00000000..dc651eed --- /dev/null +++ b/internal/providers/anthropic_stream.go @@ -0,0 +1,184 @@ +package providers + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" +) + +func (p *AnthropicProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + model := req.Model + if model == "" { + model = p.defaultModel + } + + body := p.buildRequestBody(model, req, true) + + // Retry only the connection phase; once streaming starts, no retry. + respBody, err := RetryDo(ctx, p.retryConfig, func() (io.ReadCloser, error) { + return p.doRequest(ctx, body) + }) + if err != nil { + return nil, err + } + defer respBody.Close() + + result := &ChatResponse{FinishReason: "stop"} + // Accumulate raw JSON fragments for each tool call by index + toolCallJSON := make(map[int]string) + + // Track content blocks for RawAssistantContent (needed for thinking block passback) + var rawContentBlocks []json.RawMessage + var currentBlockType string + // Track thinking token count by accumulated chunk size + thinkingChars := 0 + + scanner := bufio.NewScanner(respBody) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 1MB max line for large thinking chunks + var currentEvent string + + for scanner.Scan() { + line := scanner.Text() + + // Track event type + if strings.HasPrefix(line, "event: ") { + currentEvent = strings.TrimPrefix(line, "event: ") + continue + } + + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + + switch currentEvent { + case "message_start": + var ev anthropicMessageStartEvent + if err := json.Unmarshal([]byte(data), &ev); err == nil { + if result.Usage == nil { + result.Usage = &Usage{} + } + if ev.Message.Usage.InputTokens > 0 { + result.Usage.PromptTokens = ev.Message.Usage.InputTokens + } + result.Usage.CacheCreationTokens = ev.Message.Usage.CacheCreationInputTokens + result.Usage.CacheReadTokens = ev.Message.Usage.CacheReadInputTokens + } + + case "content_block_start": + var ev anthropicContentBlockStartEvent + if err := json.Unmarshal([]byte(data), &ev); err == nil { + currentBlockType = ev.ContentBlock.Type + if ev.ContentBlock.Type == "tool_use" { + result.ToolCalls = append(result.ToolCalls, ToolCall{ + ID: ev.ContentBlock.ID, + Name: strings.TrimSpace(ev.ContentBlock.Name), + Arguments: make(map[string]interface{}), + }) + } + // Store raw content_block for later reconstruction + rawContentBlocks = append(rawContentBlocks, json.RawMessage(fmt.Sprintf(`{"type":"%s"`, ev.ContentBlock.Type))) + } + + case "content_block_delta": + var ev anthropicContentBlockDeltaEvent + if err := json.Unmarshal([]byte(data), &ev); err == nil { + switch ev.Delta.Type { + case "text_delta": + result.Content += ev.Delta.Text + if onChunk != nil { + onChunk(StreamChunk{Content: ev.Delta.Text}) + } + case "thinking_delta": + result.Thinking += ev.Delta.Thinking + thinkingChars += len(ev.Delta.Thinking) + if onChunk != nil { + onChunk(StreamChunk{Thinking: ev.Delta.Thinking}) + } + case "input_json_delta": + if len(result.ToolCalls) > 0 { + idx := len(result.ToolCalls) - 1 + toolCallJSON[idx] += ev.Delta.PartialJSON + } + case "signature_delta": + // Signature is captured in content_block_stop via raw block reconstruction + } + } + + case "content_block_stop": + // Reconstruct the complete content block for RawAssistantContent + if len(rawContentBlocks) > 0 { + idx := len(rawContentBlocks) - 1 + block := p.buildRawBlock(currentBlockType, result, toolCallJSON, idx) + if block != nil { + rawContentBlocks[idx] = block + } + } + currentBlockType = "" + + case "message_delta": + var ev anthropicMessageDeltaEvent + if err := json.Unmarshal([]byte(data), &ev); err == nil { + if ev.Delta.StopReason != "" { + switch ev.Delta.StopReason { + case "tool_use": + result.FinishReason = "tool_calls" + case "max_tokens": + result.FinishReason = "length" + default: + result.FinishReason = "stop" + } + } + if ev.Usage.OutputTokens > 0 { + if result.Usage == nil { + result.Usage = &Usage{} + } + result.Usage.CompletionTokens = ev.Usage.OutputTokens + } + } + + case "error": + var ev anthropicErrorEvent + if err := json.Unmarshal([]byte(data), &ev); err == nil { + return nil, fmt.Errorf("anthropic stream error: %s: %s", ev.Error.Type, ev.Error.Message) + } + + case "message_stop": + // Stream complete + } + } + + // Parse accumulated tool call JSON arguments + for i, rawJSON := range toolCallJSON { + if rawJSON != "" { + args := make(map[string]interface{}) + _ = json.Unmarshal([]byte(rawJSON), &args) + result.ToolCalls[i].Arguments = args + } + } + + if result.Usage != nil { + result.Usage.TotalTokens = result.Usage.PromptTokens + result.Usage.CompletionTokens + // Estimate thinking tokens from accumulated character count (~4 chars per token) + if thinkingChars > 0 { + result.Usage.ThinkingTokens = thinkingChars / 4 + } + } + + // Preserve raw content blocks for tool use passback + if len(rawContentBlocks) > 0 && len(result.ToolCalls) > 0 { + if b, err := json.Marshal(rawContentBlocks); err == nil { + result.RawAssistantContent = b + } + } + + if onChunk != nil { + onChunk(StreamChunk{Done: true}) + } + + return result, nil +} diff --git a/internal/store/pg/mcp_servers.go b/internal/store/pg/mcp_servers.go index 5fd8648e..2f9417a9 100644 --- a/internal/store/pg/mcp_servers.go +++ b/internal/store/pg/mcp_servers.go @@ -3,7 +3,6 @@ package pg import ( "context" "database/sql" - "encoding/json" "fmt" "log/slog" "time" @@ -158,265 +157,3 @@ func (s *PGMCPServerStore) DeleteServer(ctx context.Context, id uuid.UUID) error _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1", id) return err } - -// --- Agent Grants --- - -func (s *PGMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGrant) error { - if err := store.ValidateUserID(g.GrantedBy); err != nil { - return err - } - if g.ID == uuid.Nil { - g.ID = store.GenNewID() - } - g.CreatedAt = time.Now() - _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - ON CONFLICT (server_id, agent_id) DO UPDATE SET - enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, - tool_deny = EXCLUDED.tool_deny, config_overrides = EXCLUDED.config_overrides, - granted_by = EXCLUDED.granted_by`, - g.ID, g.ServerID, g.AgentID, g.Enabled, - jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), - g.GrantedBy, g.CreatedAt, - ) - return err -} - -func (s *PGMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2", serverID, agentID) - return err -} - -func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at - FROM mcp_agent_grants WHERE agent_id = $1`, agentID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []store.MCPAgentGrant - for rows.Next() { - var g store.MCPAgentGrant - if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled, - &g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &g.CreatedAt); err != nil { - continue - } - result = append(result, g) - } - return result, nil -} - -// --- User Grants --- - -func (s *PGMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGrant) error { - if err := store.ValidateUserID(g.UserID); err != nil { - return err - } - if err := store.ValidateUserID(g.GrantedBy); err != nil { - return err - } - if g.ID == uuid.Nil { - g.ID = store.GenNewID() - } - g.CreatedAt = time.Now() - _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) - ON CONFLICT (server_id, user_id) DO UPDATE SET - enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, - tool_deny = EXCLUDED.tool_deny, granted_by = EXCLUDED.granted_by`, - g.ID, g.ServerID, g.UserID, g.Enabled, - jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), - g.GrantedBy, g.CreatedAt, - ) - return err -} - -func (s *PGMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2", serverID, userID) - return err -} - -// --- Resolution --- - -func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env, - ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at, - mag.tool_allow, mag.tool_deny - FROM mcp_servers ms - INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = $1 AND mag.enabled = true - LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = $2 - WHERE ms.enabled = true - AND (mug.id IS NULL OR mug.enabled = true)`, - agentID, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []store.MCPAccessInfo - for rows.Next() { - var srv store.MCPServerData - var displayName, command, url, apiKey, toolPrefix *string - var toolAllowJSON, toolDenyJSON []byte - - if err := rows.Scan( - &srv.ID, &srv.Name, &displayName, &srv.Transport, &command, - &srv.Args, &url, &srv.Headers, &srv.Env, - &apiKey, &toolPrefix, &srv.TimeoutSec, - &srv.Settings, &srv.Enabled, &srv.CreatedBy, &srv.CreatedAt, &srv.UpdatedAt, - &toolAllowJSON, &toolDenyJSON, - ); err != nil { - continue - } - srv.DisplayName = derefStr(displayName) - srv.Command = derefStr(command) - srv.URL = derefStr(url) - srv.ToolPrefix = derefStr(toolPrefix) - if apiKey != nil && *apiKey != "" && s.encKey != "" { - if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil { - srv.APIKey = decrypted - } - } else { - srv.APIKey = derefStr(apiKey) - } - - info := store.MCPAccessInfo{Server: srv} - if toolAllowJSON != nil { - json.Unmarshal(toolAllowJSON, &info.ToolAllow) - } - if toolDenyJSON != nil { - json.Unmarshal(toolDenyJSON, &info.ToolDeny) - } - result = append(result, info) - } - return result, nil -} - -// --- Access Requests --- - -func (s *PGMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAccessRequest) error { - if err := store.ValidateUserID(req.RequestedBy); err != nil { - return err - } - if req.ID == uuid.Nil { - req.ID = store.GenNewID() - } - req.Status = "pending" - req.CreatedAt = time.Now() - _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, - req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID), - req.Scope, req.Status, nilStr(req.Reason), - jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, - ) - return err -} - -func (s *PGMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, - reviewed_by, reviewed_at, review_note, created_at - FROM mcp_access_requests WHERE status = 'pending' ORDER BY created_at`) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []store.MCPAccessRequest - for rows.Next() { - var r store.MCPAccessRequest - var agentID *uuid.UUID - var userID, reviewedBy, reviewNote *string - if err := rows.Scan(&r.ID, &r.ServerID, &agentID, &userID, &r.Scope, &r.Status, - &r.Reason, &r.ToolAllow, &r.RequestedBy, - &reviewedBy, &r.ReviewedAt, &reviewNote, &r.CreatedAt); err != nil { - continue - } - r.AgentID = agentID - r.UserID = derefStr(userID) - r.ReviewedBy = derefStr(reviewedBy) - r.ReviewNote = derefStr(reviewNote) - result = append(result, r) - } - return result, nil -} - -func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error { - if err := store.ValidateUserID(reviewedBy); err != nil { - return err - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Load the request - var req store.MCPAccessRequest - var agentID *uuid.UUID - var userID *string - err = tx.QueryRowContext(ctx, - `SELECT id, server_id, agent_id, user_id, scope, status, tool_allow - FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`, requestID, - ).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow) - if err != nil { - return fmt.Errorf("request not found or not pending: %w", err) - } - - status := "rejected" - if approved { - status = "approved" - } - now := time.Now() - - // Update request status - _, err = tx.ExecContext(ctx, - `UPDATE mcp_access_requests SET status = $1, reviewed_by = $2, reviewed_at = $3, review_note = $4 WHERE id = $5`, - status, reviewedBy, now, nilStr(note), requestID, - ) - if err != nil { - return err - } - - // If approved, insert the grant - if approved { - switch req.Scope { - case "agent": - if agentID == nil { - return fmt.Errorf("agent_id required for agent scope") - } - _, err = tx.ExecContext(ctx, - `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at) - VALUES ($1,$2,$3,true,$4,$5,$6) - ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, - ) - case "user": - if userID == nil || *userID == "" { - return fmt.Errorf("user_id required for user scope") - } - _, err = tx.ExecContext(ctx, - `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at) - VALUES ($1,$2,$3,true,$4,$5,$6) - ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, - ) - default: - return fmt.Errorf("unknown scope: %s", req.Scope) - } - if err != nil { - return err - } - } - - return tx.Commit() -} diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go new file mode 100644 index 00000000..d4544d1f --- /dev/null +++ b/internal/store/pg/mcp_servers_access.go @@ -0,0 +1,275 @@ +package pg + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// --- Agent Grants --- + +func (s *PGMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGrant) error { + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + g.CreatedAt = time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (server_id, agent_id) DO UPDATE SET + enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, + tool_deny = EXCLUDED.tool_deny, config_overrides = EXCLUDED.config_overrides, + granted_by = EXCLUDED.granted_by`, + g.ID, g.ServerID, g.AgentID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), + g.GrantedBy, g.CreatedAt, + ) + return err +} + +func (s *PGMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2", serverID, agentID) + return err +} + +func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at + FROM mcp_agent_grants WHERE agent_id = $1`, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.MCPAgentGrant + for rows.Next() { + var g store.MCPAgentGrant + if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled, + &g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &g.CreatedAt); err != nil { + continue + } + result = append(result, g) + } + return result, nil +} + +// --- User Grants --- + +func (s *PGMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGrant) error { + if err := store.ValidateUserID(g.UserID); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + g.CreatedAt = time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (server_id, user_id) DO UPDATE SET + enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, + tool_deny = EXCLUDED.tool_deny, granted_by = EXCLUDED.granted_by`, + g.ID, g.ServerID, g.UserID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), + g.GrantedBy, g.CreatedAt, + ) + return err +} + +func (s *PGMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error { + _, err := s.db.ExecContext(ctx, + "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2", serverID, userID) + return err +} + +// --- Resolution --- + +func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env, + ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at, + mag.tool_allow, mag.tool_deny + FROM mcp_servers ms + INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = $1 AND mag.enabled = true + LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = $2 + WHERE ms.enabled = true + AND (mug.id IS NULL OR mug.enabled = true)`, + agentID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.MCPAccessInfo + for rows.Next() { + var srv store.MCPServerData + var displayName, command, url, apiKey, toolPrefix *string + var toolAllowJSON, toolDenyJSON []byte + + if err := rows.Scan( + &srv.ID, &srv.Name, &displayName, &srv.Transport, &command, + &srv.Args, &url, &srv.Headers, &srv.Env, + &apiKey, &toolPrefix, &srv.TimeoutSec, + &srv.Settings, &srv.Enabled, &srv.CreatedBy, &srv.CreatedAt, &srv.UpdatedAt, + &toolAllowJSON, &toolDenyJSON, + ); err != nil { + continue + } + srv.DisplayName = derefStr(displayName) + srv.Command = derefStr(command) + srv.URL = derefStr(url) + srv.ToolPrefix = derefStr(toolPrefix) + if apiKey != nil && *apiKey != "" && s.encKey != "" { + if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil { + srv.APIKey = decrypted + } + } else { + srv.APIKey = derefStr(apiKey) + } + + info := store.MCPAccessInfo{Server: srv} + if toolAllowJSON != nil { + json.Unmarshal(toolAllowJSON, &info.ToolAllow) + } + if toolDenyJSON != nil { + json.Unmarshal(toolDenyJSON, &info.ToolDeny) + } + result = append(result, info) + } + return result, nil +} + +// --- Access Requests --- + +func (s *PGMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAccessRequest) error { + if err := store.ValidateUserID(req.RequestedBy); err != nil { + return err + } + if req.ID == uuid.Nil { + req.ID = store.GenNewID() + } + req.Status = "pending" + req.CreatedAt = time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID), + req.Scope, req.Status, nilStr(req.Reason), + jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, + ) + return err +} + +func (s *PGMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, + reviewed_by, reviewed_at, review_note, created_at + FROM mcp_access_requests WHERE status = 'pending' ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.MCPAccessRequest + for rows.Next() { + var r store.MCPAccessRequest + var agentID *uuid.UUID + var userID, reviewedBy, reviewNote *string + if err := rows.Scan(&r.ID, &r.ServerID, &agentID, &userID, &r.Scope, &r.Status, + &r.Reason, &r.ToolAllow, &r.RequestedBy, + &reviewedBy, &r.ReviewedAt, &reviewNote, &r.CreatedAt); err != nil { + continue + } + r.AgentID = agentID + r.UserID = derefStr(userID) + r.ReviewedBy = derefStr(reviewedBy) + r.ReviewNote = derefStr(reviewNote) + result = append(result, r) + } + return result, nil +} + +func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error { + if err := store.ValidateUserID(reviewedBy); err != nil { + return err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Load the request + var req store.MCPAccessRequest + var agentID *uuid.UUID + var userID *string + err = tx.QueryRowContext(ctx, + `SELECT id, server_id, agent_id, user_id, scope, status, tool_allow + FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`, requestID, + ).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow) + if err != nil { + return fmt.Errorf("request not found or not pending: %w", err) + } + + status := "rejected" + if approved { + status = "approved" + } + now := time.Now() + + // Update request status + _, err = tx.ExecContext(ctx, + `UPDATE mcp_access_requests SET status = $1, reviewed_by = $2, reviewed_at = $3, review_note = $4 WHERE id = $5`, + status, reviewedBy, now, nilStr(note), requestID, + ) + if err != nil { + return err + } + + // If approved, insert the grant + if approved { + switch req.Scope { + case "agent": + if agentID == nil { + return fmt.Errorf("agent_id required for agent scope") + } + _, err = tx.ExecContext(ctx, + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at) + VALUES ($1,$2,$3,true,$4,$5,$6) + ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, + store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, + ) + case "user": + if userID == nil || *userID == "" { + return fmt.Errorf("user_id required for user scope") + } + _, err = tx.ExecContext(ctx, + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at) + VALUES ($1,$2,$3,true,$4,$5,$6) + ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, + store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, + ) + default: + return fmt.Errorf("unknown scope: %s", req.Scope) + } + if err != nil { + return err + } + } + + return tx.Commit() +} diff --git a/internal/store/pg/teams.go b/internal/store/pg/teams.go index a2f36a5e..e3733b3e 100644 --- a/internal/store/pg/teams.go +++ b/internal/store/pg/teams.go @@ -4,11 +4,9 @@ import ( "context" "database/sql" "encoding/json" - "fmt" "time" "github.com/google/uuid" - "github.com/lib/pq" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -164,312 +162,6 @@ func (s *PGTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (* return d, err } -// ============================================================ -// Tasks -// ============================================================ - -func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) error { - if task.ID == uuid.Nil { - task.ID = store.GenNewID() - } - now := time.Now() - task.CreatedAt = now - task.UpdatedAt = now - - _, err := s.db.ExecContext(ctx, - `INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, - task.ID, task.TeamID, task.Subject, task.Description, - task.Status, task.OwnerAgentID, pq.Array(task.BlockedBy), - task.Priority, task.Result, now, now, - ) - return err -} - -func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error { - if len(updates) == 0 { - return nil - } - updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates) -} - -func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string) ([]store.TeamTaskData, error) { - orderClause := "t.priority DESC, t.created_at" - if orderBy == "newest" { - orderClause = "t.created_at DESC" - } - - statusWhere := "AND t.status != 'completed'" // default: active only - switch statusFilter { - case store.TeamTaskFilterAll: - statusWhere = "" - case store.TeamTaskFilterCompleted: - statusWhere = "AND t.status = 'completed'" - } - - rows, err := s.db.QueryContext(ctx, - `SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.created_at, t.updated_at, - COALESCE(a.agent_key, '') AS owner_agent_key - FROM team_tasks t - LEFT JOIN agents a ON a.id = t.owner_agent_id - WHERE t.team_id = $1 `+statusWhere+` - ORDER BY `+orderClause, teamID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanTaskRowsJoined(rows) -} - -func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.created_at, t.updated_at, - COALESCE(a.agent_key, '') AS owner_agent_key - FROM team_tasks t - LEFT JOIN agents a ON a.id = t.owner_agent_id - WHERE t.id = $1`, taskID) - if err != nil { - return nil, err - } - defer rows.Close() - tasks, err := scanTaskRowsJoined(rows) - if err != nil { - return nil, err - } - if len(tasks) == 0 { - return nil, fmt.Errorf("task not found") - } - return &tasks[0], nil -} - -func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int) ([]store.TeamTaskData, error) { - if limit <= 0 { - limit = 20 - } - rows, err := s.db.QueryContext(ctx, - `SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.created_at, t.updated_at, - COALESCE(a.agent_key, '') AS owner_agent_key - FROM team_tasks t - LEFT JOIN agents a ON a.id = t.owner_agent_id - WHERE t.team_id = $1 AND t.tsv @@ plainto_tsquery('simple', $2) - ORDER BY ts_rank(t.tsv, plainto_tsquery('simple', $2)) DESC - LIMIT $3`, teamID, query, limit) - if err != nil { - return nil, err - } - defer rows.Close() - return scanTaskRowsJoined(rows) -} - -func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID uuid.UUID) error { - res, err := s.db.ExecContext(ctx, - `UPDATE team_tasks SET status = $1, owner_agent_id = $2, updated_at = $3 - WHERE id = $4 AND status = $5 AND owner_agent_id IS NULL`, - store.TeamTaskStatusInProgress, agentID, time.Now(), - taskID, store.TeamTaskStatusPending, - ) - if err != nil { - return err - } - n, err := res.RowsAffected() - if err != nil { - return err - } - if n == 0 { - return fmt.Errorf("task not available for claiming (already claimed or not pending)") - } - return nil -} - -func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID uuid.UUID, result string) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Mark task as completed (must be in_progress — use ClaimTask first) - res, err := tx.ExecContext(ctx, - `UPDATE team_tasks SET status = $1, result = $2, updated_at = $3 - WHERE id = $4 AND status = $5`, - store.TeamTaskStatusCompleted, result, time.Now(), - taskID, store.TeamTaskStatusInProgress, - ) - if err != nil { - return err - } - n, err := res.RowsAffected() - if err != nil { - return err - } - if n == 0 { - return fmt.Errorf("task not in progress or not found") - } - - // Unblock dependent tasks: remove this taskID from their blocked_by arrays. - // Tasks with empty blocked_by after removal become claimable. - _, err = tx.ExecContext(ctx, - `UPDATE team_tasks SET blocked_by = array_remove(blocked_by, $1), updated_at = $2 - WHERE $1 = ANY(blocked_by)`, - taskID, time.Now(), - ) - if err != nil { - return err - } - - return tx.Commit() -} - -// ============================================================ -// Delegation History -// ============================================================ - -func (s *PGTeamStore) SaveDelegationHistory(ctx context.Context, record *store.DelegationHistoryData) error { - if record.ID == uuid.Nil { - record.ID = store.GenNewID() - } - now := time.Now() - record.CreatedAt = now - - _, err := s.db.ExecContext(ctx, - `INSERT INTO delegation_history (id, source_agent_id, target_agent_id, team_id, team_task_id, user_id, task, mode, status, result, error, iterations, trace_id, duration_ms, created_at, completed_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, - record.ID, record.SourceAgentID, record.TargetAgentID, - record.TeamID, record.TeamTaskID, - record.UserID, record.Task, record.Mode, record.Status, - record.Result, record.Error, record.Iterations, - record.TraceID, record.DurationMS, now, record.CompletedAt, - ) - return err -} - -func (s *PGTeamStore) ListDelegationHistory(ctx context.Context, opts store.DelegationHistoryListOpts) ([]store.DelegationHistoryData, int, error) { - where := "WHERE 1=1" - args := []any{} - argN := 0 - - nextArg := func(v any) string { - argN++ - args = append(args, v) - return fmt.Sprintf("$%d", argN) - } - - if opts.SourceAgentID != nil { - where += " AND d.source_agent_id = " + nextArg(*opts.SourceAgentID) - } - if opts.TargetAgentID != nil { - where += " AND d.target_agent_id = " + nextArg(*opts.TargetAgentID) - } - if opts.TeamID != nil { - where += " AND d.team_id = " + nextArg(*opts.TeamID) - } - if opts.UserID != "" { - where += " AND d.user_id = " + nextArg(opts.UserID) - } - if opts.Status != "" { - where += " AND d.status = " + nextArg(opts.Status) - } - - // Count total - var total int - countSQL := "SELECT COUNT(*) FROM delegation_history d " + where - if err := s.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil { - return nil, 0, err - } - - // Fetch rows - limit := opts.Limit - if limit <= 0 || limit > 200 { - limit = 50 - } - offset := opts.Offset - if offset < 0 { - offset = 0 - } - - query := fmt.Sprintf( - `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, - d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, - d.trace_id, d.duration_ms, d.created_at, d.completed_at, - COALESCE(sa.agent_key, '') AS source_agent_key, - COALESCE(ta.agent_key, '') AS target_agent_key - FROM delegation_history d - LEFT JOIN agents sa ON sa.id = d.source_agent_id - LEFT JOIN agents ta ON ta.id = d.target_agent_id - %s - ORDER BY d.created_at DESC - LIMIT %s OFFSET %s`, - where, nextArg(limit), nextArg(offset)) - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var records []store.DelegationHistoryData - for rows.Next() { - var d store.DelegationHistoryData - var result, errStr sql.NullString - var completedAt sql.NullTime - if err := rows.Scan( - &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, - &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, - &d.TraceID, &d.DurationMS, &d.CreatedAt, &completedAt, - &d.SourceAgentKey, &d.TargetAgentKey, - ); err != nil { - return nil, 0, err - } - if result.Valid { - d.Result = &result.String - } - if errStr.Valid { - d.Error = &errStr.String - } - if completedAt.Valid { - d.CompletedAt = &completedAt.Time - } - records = append(records, d) - } - return records, total, rows.Err() -} - -func (s *PGTeamStore) GetDelegationHistory(ctx context.Context, id uuid.UUID) (*store.DelegationHistoryData, error) { - var d store.DelegationHistoryData - var result, errStr sql.NullString - var completedAt sql.NullTime - - err := s.db.QueryRowContext(ctx, - `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, - d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, - d.trace_id, d.duration_ms, d.created_at, d.completed_at, - COALESCE(sa.agent_key, '') AS source_agent_key, - COALESCE(ta.agent_key, '') AS target_agent_key - FROM delegation_history d - LEFT JOIN agents sa ON sa.id = d.source_agent_id - LEFT JOIN agents ta ON ta.id = d.target_agent_id - WHERE d.id = $1`, id).Scan( - &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, - &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, - &d.TraceID, &d.DurationMS, &d.CreatedAt, &completedAt, - &d.SourceAgentKey, &d.TargetAgentKey, - ) - if err != nil { - return nil, err - } - if result.Valid { - d.Result = &result.String - } - if errStr.Valid { - d.Error = &errStr.String - } - if completedAt.Valid { - d.CompletedAt = &completedAt.Time - } - return &d, nil -} - // ============================================================ // Handoff routing // ============================================================ @@ -517,48 +209,6 @@ func (s *PGTeamStore) ClearHandoffRoute(ctx context.Context, channel, chatID str return err } -// ============================================================ -// Messages -// ============================================================ - -func (s *PGTeamStore) SendMessage(ctx context.Context, msg *store.TeamMessageData) error { - if msg.ID == uuid.Nil { - msg.ID = store.GenNewID() - } - msg.CreatedAt = time.Now() - - _, err := s.db.ExecContext(ctx, - `INSERT INTO team_messages (id, team_id, from_agent_id, to_agent_id, content, message_type, read, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - msg.ID, msg.TeamID, msg.FromAgentID, msg.ToAgentID, - msg.Content, msg.MessageType, false, msg.CreatedAt, - ) - return err -} - -func (s *PGTeamStore) GetUnread(ctx context.Context, teamID, agentID uuid.UUID) ([]store.TeamMessageData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT m.id, m.team_id, m.from_agent_id, m.to_agent_id, m.content, m.message_type, m.read, m.created_at, - COALESCE(fa.agent_key, '') AS from_agent_key, - COALESCE(ta.agent_key, '') AS to_agent_key - FROM team_messages m - LEFT JOIN agents fa ON fa.id = m.from_agent_id - LEFT JOIN agents ta ON ta.id = m.to_agent_id - WHERE m.team_id = $1 AND (m.to_agent_id = $2 OR m.to_agent_id IS NULL) AND m.read = false - ORDER BY m.created_at`, teamID, agentID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanMessageRowsJoined(rows) -} - -func (s *PGTeamStore) MarkRead(ctx context.Context, messageID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, - `UPDATE team_messages SET read = true WHERE id = $1`, messageID) - return err -} - // ============================================================ // Scan helpers // ============================================================ @@ -578,49 +228,3 @@ func scanTeamRow(row *sql.Row) (*store.TeamData, error) { } return &d, nil } - -func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) { - var tasks []store.TeamTaskData - for rows.Next() { - var d store.TeamTaskData - var desc, result sql.NullString - var ownerID *uuid.UUID - var blockedBy []uuid.UUID - if err := rows.Scan( - &d.ID, &d.TeamID, &d.Subject, &desc, &d.Status, - &ownerID, pq.Array(&blockedBy), &d.Priority, &result, - &d.CreatedAt, &d.UpdatedAt, - &d.OwnerAgentKey, - ); err != nil { - return nil, err - } - if desc.Valid { - d.Description = desc.String - } - if result.Valid { - d.Result = &result.String - } - d.OwnerAgentID = ownerID - d.BlockedBy = blockedBy - tasks = append(tasks, d) - } - return tasks, rows.Err() -} - -func scanMessageRowsJoined(rows *sql.Rows) ([]store.TeamMessageData, error) { - var messages []store.TeamMessageData - for rows.Next() { - var d store.TeamMessageData - var toAgentID *uuid.UUID - if err := rows.Scan( - &d.ID, &d.TeamID, &d.FromAgentID, &toAgentID, - &d.Content, &d.MessageType, &d.Read, &d.CreatedAt, - &d.FromAgentKey, &d.ToAgentKey, - ); err != nil { - return nil, err - } - d.ToAgentID = toAgentID - messages = append(messages, d) - } - return messages, rows.Err() -} diff --git a/internal/store/pg/teams_delegation.go b/internal/store/pg/teams_delegation.go new file mode 100644 index 00000000..f9ca35e0 --- /dev/null +++ b/internal/store/pg/teams_delegation.go @@ -0,0 +1,161 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ============================================================ +// Delegation History +// ============================================================ + +func (s *PGTeamStore) SaveDelegationHistory(ctx context.Context, record *store.DelegationHistoryData) error { + if record.ID == uuid.Nil { + record.ID = store.GenNewID() + } + now := time.Now() + record.CreatedAt = now + + _, err := s.db.ExecContext(ctx, + `INSERT INTO delegation_history (id, source_agent_id, target_agent_id, team_id, team_task_id, user_id, task, mode, status, result, error, iterations, trace_id, duration_ms, created_at, completed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + record.ID, record.SourceAgentID, record.TargetAgentID, + record.TeamID, record.TeamTaskID, + record.UserID, record.Task, record.Mode, record.Status, + record.Result, record.Error, record.Iterations, + record.TraceID, record.DurationMS, now, record.CompletedAt, + ) + return err +} + +func (s *PGTeamStore) ListDelegationHistory(ctx context.Context, opts store.DelegationHistoryListOpts) ([]store.DelegationHistoryData, int, error) { + where := "WHERE 1=1" + args := []any{} + argN := 0 + + nextArg := func(v any) string { + argN++ + args = append(args, v) + return fmt.Sprintf("$%d", argN) + } + + if opts.SourceAgentID != nil { + where += " AND d.source_agent_id = " + nextArg(*opts.SourceAgentID) + } + if opts.TargetAgentID != nil { + where += " AND d.target_agent_id = " + nextArg(*opts.TargetAgentID) + } + if opts.TeamID != nil { + where += " AND d.team_id = " + nextArg(*opts.TeamID) + } + if opts.UserID != "" { + where += " AND d.user_id = " + nextArg(opts.UserID) + } + if opts.Status != "" { + where += " AND d.status = " + nextArg(opts.Status) + } + + // Count total + var total int + countSQL := "SELECT COUNT(*) FROM delegation_history d " + where + if err := s.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + + // Fetch rows + limit := opts.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + offset := opts.Offset + if offset < 0 { + offset = 0 + } + + query := fmt.Sprintf( + `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, + d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, + d.trace_id, d.duration_ms, d.created_at, d.completed_at, + COALESCE(sa.agent_key, '') AS source_agent_key, + COALESCE(ta.agent_key, '') AS target_agent_key + FROM delegation_history d + LEFT JOIN agents sa ON sa.id = d.source_agent_id + LEFT JOIN agents ta ON ta.id = d.target_agent_id + %s + ORDER BY d.created_at DESC + LIMIT %s OFFSET %s`, + where, nextArg(limit), nextArg(offset)) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var records []store.DelegationHistoryData + for rows.Next() { + var d store.DelegationHistoryData + var result, errStr sql.NullString + var completedAt sql.NullTime + if err := rows.Scan( + &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, + &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, + &d.TraceID, &d.DurationMS, &d.CreatedAt, &completedAt, + &d.SourceAgentKey, &d.TargetAgentKey, + ); err != nil { + return nil, 0, err + } + if result.Valid { + d.Result = &result.String + } + if errStr.Valid { + d.Error = &errStr.String + } + if completedAt.Valid { + d.CompletedAt = &completedAt.Time + } + records = append(records, d) + } + return records, total, rows.Err() +} + +func (s *PGTeamStore) GetDelegationHistory(ctx context.Context, id uuid.UUID) (*store.DelegationHistoryData, error) { + var d store.DelegationHistoryData + var result, errStr sql.NullString + var completedAt sql.NullTime + + err := s.db.QueryRowContext(ctx, + `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, + d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, + d.trace_id, d.duration_ms, d.created_at, d.completed_at, + COALESCE(sa.agent_key, '') AS source_agent_key, + COALESCE(ta.agent_key, '') AS target_agent_key + FROM delegation_history d + LEFT JOIN agents sa ON sa.id = d.source_agent_id + LEFT JOIN agents ta ON ta.id = d.target_agent_id + WHERE d.id = $1`, id).Scan( + &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, + &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, + &d.TraceID, &d.DurationMS, &d.CreatedAt, &completedAt, + &d.SourceAgentKey, &d.TargetAgentKey, + ) + if err != nil { + return nil, err + } + if result.Valid { + d.Result = &result.String + } + if errStr.Valid { + d.Error = &errStr.String + } + if completedAt.Valid { + d.CompletedAt = &completedAt.Time + } + return &d, nil +} diff --git a/internal/store/pg/teams_messaging.go b/internal/store/pg/teams_messaging.go new file mode 100644 index 00000000..af5d1bd4 --- /dev/null +++ b/internal/store/pg/teams_messaging.go @@ -0,0 +1,71 @@ +package pg + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ============================================================ +// Messages +// ============================================================ + +func (s *PGTeamStore) SendMessage(ctx context.Context, msg *store.TeamMessageData) error { + if msg.ID == uuid.Nil { + msg.ID = store.GenNewID() + } + msg.CreatedAt = time.Now() + + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_messages (id, team_id, from_agent_id, to_agent_id, content, message_type, read, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + msg.ID, msg.TeamID, msg.FromAgentID, msg.ToAgentID, + msg.Content, msg.MessageType, false, msg.CreatedAt, + ) + return err +} + +func (s *PGTeamStore) GetUnread(ctx context.Context, teamID, agentID uuid.UUID) ([]store.TeamMessageData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, m.team_id, m.from_agent_id, m.to_agent_id, m.content, m.message_type, m.read, m.created_at, + COALESCE(fa.agent_key, '') AS from_agent_key, + COALESCE(ta.agent_key, '') AS to_agent_key + FROM team_messages m + LEFT JOIN agents fa ON fa.id = m.from_agent_id + LEFT JOIN agents ta ON ta.id = m.to_agent_id + WHERE m.team_id = $1 AND (m.to_agent_id = $2 OR m.to_agent_id IS NULL) AND m.read = false + ORDER BY m.created_at`, teamID, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanMessageRowsJoined(rows) +} + +func (s *PGTeamStore) MarkRead(ctx context.Context, messageID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `UPDATE team_messages SET read = true WHERE id = $1`, messageID) + return err +} + +func scanMessageRowsJoined(rows *sql.Rows) ([]store.TeamMessageData, error) { + var messages []store.TeamMessageData + for rows.Next() { + var d store.TeamMessageData + var toAgentID *uuid.UUID + if err := rows.Scan( + &d.ID, &d.TeamID, &d.FromAgentID, &toAgentID, + &d.Content, &d.MessageType, &d.Read, &d.CreatedAt, + &d.FromAgentKey, &d.ToAgentKey, + ); err != nil { + return nil, err + } + d.ToAgentID = toAgentID + messages = append(messages, d) + } + return messages, rows.Err() +} diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go new file mode 100644 index 00000000..47b6729d --- /dev/null +++ b/internal/store/pg/teams_tasks.go @@ -0,0 +1,198 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ============================================================ +// Tasks +// ============================================================ + +func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) error { + if task.ID == uuid.Nil { + task.ID = store.GenNewID() + } + now := time.Now() + task.CreatedAt = now + task.UpdatedAt = now + + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + task.ID, task.TeamID, task.Subject, task.Description, + task.Status, task.OwnerAgentID, pq.Array(task.BlockedBy), + task.Priority, task.Result, now, now, + ) + return err +} + +func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + updates["updated_at"] = time.Now() + return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates) +} + +func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string) ([]store.TeamTaskData, error) { + orderClause := "t.priority DESC, t.created_at" + if orderBy == "newest" { + orderClause = "t.created_at DESC" + } + + statusWhere := "AND t.status != 'completed'" // default: active only + switch statusFilter { + case store.TeamTaskFilterAll: + statusWhere = "" + case store.TeamTaskFilterCompleted: + statusWhere = "AND t.status = 'completed'" + } + + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.created_at, t.updated_at, + COALESCE(a.agent_key, '') AS owner_agent_key + FROM team_tasks t + LEFT JOIN agents a ON a.id = t.owner_agent_id + WHERE t.team_id = $1 `+statusWhere+` + ORDER BY `+orderClause, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.created_at, t.updated_at, + COALESCE(a.agent_key, '') AS owner_agent_key + FROM team_tasks t + LEFT JOIN agents a ON a.id = t.owner_agent_id + WHERE t.id = $1`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + tasks, err := scanTaskRowsJoined(rows) + if err != nil { + return nil, err + } + if len(tasks) == 0 { + return nil, fmt.Errorf("task not found") + } + return &tasks[0], nil +} + +func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int) ([]store.TeamTaskData, error) { + if limit <= 0 { + limit = 20 + } + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.created_at, t.updated_at, + COALESCE(a.agent_key, '') AS owner_agent_key + FROM team_tasks t + LEFT JOIN agents a ON a.id = t.owner_agent_id + WHERE t.team_id = $1 AND t.tsv @@ plainto_tsquery('simple', $2) + ORDER BY ts_rank(t.tsv, plainto_tsquery('simple', $2)) DESC + LIMIT $3`, teamID, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID uuid.UUID) error { + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = $1, owner_agent_id = $2, updated_at = $3 + WHERE id = $4 AND status = $5 AND owner_agent_id IS NULL`, + store.TeamTaskStatusInProgress, agentID, time.Now(), + taskID, store.TeamTaskStatusPending, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not available for claiming (already claimed or not pending)") + } + return nil +} + +func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID uuid.UUID, result string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Mark task as completed (must be in_progress — use ClaimTask first) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = $1, result = $2, updated_at = $3 + WHERE id = $4 AND status = $5`, + store.TeamTaskStatusCompleted, result, time.Now(), + taskID, store.TeamTaskStatusInProgress, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("task not in progress or not found") + } + + // Unblock dependent tasks: remove this taskID from their blocked_by arrays. + // Tasks with empty blocked_by after removal become claimable. + _, err = tx.ExecContext(ctx, + `UPDATE team_tasks SET blocked_by = array_remove(blocked_by, $1), updated_at = $2 + WHERE $1 = ANY(blocked_by)`, + taskID, time.Now(), + ) + if err != nil { + return err + } + + return tx.Commit() +} + +func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) { + var tasks []store.TeamTaskData + for rows.Next() { + var d store.TeamTaskData + var desc, result sql.NullString + var ownerID *uuid.UUID + var blockedBy []uuid.UUID + if err := rows.Scan( + &d.ID, &d.TeamID, &d.Subject, &desc, &d.Status, + &ownerID, pq.Array(&blockedBy), &d.Priority, &result, + &d.CreatedAt, &d.UpdatedAt, + &d.OwnerAgentKey, + ); err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + if result.Valid { + d.Result = &result.String + } + d.OwnerAgentID = ownerID + d.BlockedBy = blockedBy + tasks = append(tasks, d) + } + return tasks, rows.Err() +} diff --git a/internal/tools/delegate.go b/internal/tools/delegate.go index ac05749e..29dafc31 100644 --- a/internal/tools/delegate.go +++ b/internal/tools/delegate.go @@ -2,7 +2,6 @@ package tools import ( "context" - "encoding/json" "fmt" "log/slog" "sync" @@ -277,64 +276,6 @@ func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts) return &DelegateResult{DelegationID: task.ID}, nil } -// Cancel cancels a running delegation by ID. -func (dm *DelegateManager) Cancel(delegationID string) bool { - val, ok := dm.active.Load(delegationID) - if !ok { - return false - } - task := val.(*DelegationTask) - if task.cancelFunc != nil { - task.cancelFunc() - } - task.Status = "cancelled" - now := time.Now() - task.CompletedAt = &now - dm.active.Delete(delegationID) - dm.emitEvent("delegation.cancelled", task) - slog.Info("delegation cancelled", "id", delegationID, "target", task.TargetAgentKey) - return true -} - -// ListActive returns all active delegations for a source agent. -func (dm *DelegateManager) ListActive(sourceAgentID uuid.UUID) []*DelegationTask { - var tasks []*DelegationTask - dm.active.Range(func(_, val any) bool { - t := val.(*DelegationTask) - if t.SourceAgentID == sourceAgentID && t.Status == "running" { - tasks = append(tasks, t) - } - return true - }) - return tasks -} - -// ActiveCountForLink counts running delegations for a specific source→target pair. -func (dm *DelegateManager) ActiveCountForLink(sourceID, targetID uuid.UUID) int { - count := 0 - dm.active.Range(func(_, val any) bool { - t := val.(*DelegationTask) - if t.SourceAgentID == sourceID && t.TargetAgentID == targetID && t.Status == "running" { - count++ - } - return true - }) - return count -} - -// ActiveCountForTarget counts running delegations targeting a specific agent from all sources. -func (dm *DelegateManager) ActiveCountForTarget(targetID uuid.UUID) int { - count := 0 - dm.active.Range(func(_, val any) bool { - t := val.(*DelegationTask) - if t.TargetAgentID == targetID && t.Status == "running" { - count++ - } - return true - }) - return count -} - // --- internal helpers --- func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateOpts, mode string) (*DelegationTask, *store.AgentLinkData, error) { @@ -444,271 +385,3 @@ func (dm *DelegateManager) buildRunRequest(task *DelegationTask, message string) "- Be concise and deliver actionable results.", } } - -func checkUserPermission(settings json.RawMessage, userID string) error { - if len(settings) == 0 || string(settings) == "{}" { - return nil - } - var s linkSettings - if json.Unmarshal(settings, &s) != nil { - return nil // malformed = fail open - } - for _, denied := range s.UserDeny { - if denied == userID { - return fmt.Errorf("you are not authorized to use this delegation link") - } - } - if len(s.UserAllow) > 0 { - for _, allowed := range s.UserAllow { - if allowed == userID { - return nil - } - } - return fmt.Errorf("you are not authorized to use this delegation link") - } - return nil -} - -func parseMaxDelegationLoad(otherConfig json.RawMessage) int { - if len(otherConfig) == 0 { - return defaultMaxDelegationLoad - } - var cfg struct { - MaxDelegationLoad int `json:"max_delegation_load"` - } - if json.Unmarshal(otherConfig, &cfg) != nil || cfg.MaxDelegationLoad <= 0 { - return defaultMaxDelegationLoad - } - return cfg.MaxDelegationLoad -} - -func parseQualityGates(otherConfig json.RawMessage) []hooks.HookConfig { - if len(otherConfig) == 0 { - return nil - } - var cfg struct { - QualityGates []hooks.HookConfig `json:"quality_gates"` - } - if json.Unmarshal(otherConfig, &cfg) != nil { - return nil - } - return cfg.QualityGates -} - -// applyQualityGates evaluates quality gates on a delegation result. -// Returns the (possibly revised) result. If a blocking gate fails after all retries, -// returns the last result anyway with a logged warning (does not hard-fail the delegation). -// Only returns error on catastrophic failures (e.g. context cancelled). -func (dm *DelegateManager) applyQualityGates( - ctx context.Context, task *DelegationTask, opts DelegateOpts, - result *DelegateRunResult, -) (*DelegateRunResult, error) { - if dm.hookEngine == nil || hooks.SkipHooksFromContext(ctx) { - return result, nil - } - - sourceAgent, err := dm.agentStore.GetByID(ctx, task.SourceAgentID) - if err != nil || sourceAgent == nil { - return result, nil - } - - gates := parseQualityGates(sourceAgent.OtherConfig) - if len(gates) == 0 { - return result, nil - } - - hctx := hooks.HookContext{ - Event: "delegation.completed", - SourceAgentKey: task.SourceAgentKey, - TargetAgentKey: task.TargetAgentKey, - UserID: task.UserID, - Content: result.Content, - Task: opts.Task, - } - - for _, gate := range gates { - if gate.Event != "delegation.completed" { - continue - } - - currentResult := result - retries := gate.MaxRetries - - for attempt := 0; attempt <= retries; attempt++ { - hctx.Content = currentResult.Content - - hookResult, evalErr := dm.hookEngine.EvaluateSingleHook(ctx, gate, hctx) - if evalErr != nil { - slog.Warn("quality_gate: evaluator error, skipping", - "type", gate.Type, "delegation", task.ID, "error", evalErr) - break - } - - if hookResult.Passed { - result = currentResult - break - } - - // Gate failed - if !gate.BlockOnFailure { - slog.Warn("quality_gate: non-blocking gate failed", - "type", gate.Type, "delegation", task.ID) - break - } - - if attempt >= retries { - slog.Warn("quality_gate: max retries exceeded, accepting result", - "type", gate.Type, "delegation", task.ID, "retries", retries) - result = currentResult - break - } - - // Retry: re-run target agent with feedback - slog.Info("quality_gate: retrying delegation", - "type", gate.Type, "delegation", task.ID, - "attempt", attempt+1, "max_retries", retries) - - feedbackMsg := fmt.Sprintf( - "[Quality Gate Feedback — Retry %d/%d]\n"+ - "Your previous output did not pass quality review.\n\n"+ - "Feedback: %s\n\n"+ - "Original task: %s\n\n"+ - "Please revise your output addressing the feedback.", - attempt+1, retries, hookResult.Feedback, opts.Task) - - rerunResult, rerunErr := dm.runAgent(ctx, opts.TargetAgentKey, dm.buildRunRequest(task, feedbackMsg)) - if rerunErr != nil { - slog.Warn("quality_gate: retry run failed, accepting previous result", - "delegation", task.ID, "error", rerunErr) - result = currentResult - break - } - currentResult = rerunResult - result = currentResult - } - } - - return result, nil -} - -// trackCompleted records a delegate session key for deferred cleanup. -func (dm *DelegateManager) trackCompleted(task *DelegationTask) { - if dm.sessionStore == nil { - return - } - dm.completedMu.Lock() - dm.completedSessions = append(dm.completedSessions, task.SessionKey) - dm.completedMu.Unlock() -} - -// flushCompletedSessions deletes all tracked delegate sessions. -func (dm *DelegateManager) flushCompletedSessions() { - if dm.sessionStore == nil { - return - } - dm.completedMu.Lock() - sessions := dm.completedSessions - dm.completedSessions = nil - dm.completedMu.Unlock() - - for _, key := range sessions { - if err := dm.sessionStore.Delete(key); err != nil { - slog.Warn("delegate: session cleanup failed", "session", key, "error", err) - } - } - if len(sessions) > 0 { - slog.Info("delegate: cleaned up sessions", "count", len(sessions)) - } -} - -// autoCompleteTeamTask attempts to claim+complete the associated team task. -// Called after a delegation finishes successfully. Errors are logged but not fatal. -// On success, flushes all tracked delegate sessions (task done = context no longer needed). -func (dm *DelegateManager) autoCompleteTeamTask(task *DelegationTask, resultContent string) { - if dm.teamStore == nil || task.TeamTaskID == uuid.Nil { - return - } - _ = dm.teamStore.ClaimTask(context.Background(), task.TeamTaskID, task.TargetAgentID) - if err := dm.teamStore.CompleteTask(context.Background(), task.TeamTaskID, resultContent); err != nil { - slog.Warn("delegate: failed to auto-complete team task", - "task_id", task.TeamTaskID, "delegation_id", task.ID, "error", err) - } else { - slog.Info("delegate: auto-completed team task", - "task_id", task.TeamTaskID, "delegation_id", task.ID) - // Task done — flush delegate sessions - dm.flushCompletedSessions() - } -} - - -// saveDelegationHistory persists a delegation record to the database. -// Called after delegation completes (success, fail, or cancel). Errors are logged, not fatal. -func (dm *DelegateManager) saveDelegationHistory(task *DelegationTask, resultContent string, delegateErr error, duration time.Duration) { - if dm.teamStore == nil { - return - } - - record := &store.DelegationHistoryData{ - SourceAgentID: task.SourceAgentID, - TargetAgentID: task.TargetAgentID, - UserID: task.UserID, - Task: task.Task, - Mode: task.Mode, - Iterations: 0, - DurationMS: int(duration.Milliseconds()), - } - - if task.TeamTaskID != uuid.Nil { - record.TeamTaskID = &task.TeamTaskID - } - if task.OriginTraceID != uuid.Nil { - record.TraceID = &task.OriginTraceID - } - - now := time.Now() - record.CompletedAt = &now - - if delegateErr != nil { - record.Status = "failed" - errStr := delegateErr.Error() - record.Error = &errStr - } else { - record.Status = "completed" - record.Result = &resultContent - } - - if err := dm.teamStore.SaveDelegationHistory(context.Background(), record); err != nil { - slog.Warn("delegate: failed to save delegation history", - "delegation_id", task.ID, "error", err) - } -} - -func (dm *DelegateManager) emitEvent(name string, task *DelegationTask) { - if dm.msgBus == nil { - return - } - dm.msgBus.Broadcast(bus.Event{ - Name: name, - Payload: map[string]string{ - "delegation_id": task.ID, - "source_agent": task.SourceAgentID.String(), - "target_agent": task.TargetAgentKey, - "user_id": task.UserID, - "mode": task.Mode, - }, - }) -} - -func formatDelegateAnnounce(task *DelegationTask, result *DelegateRunResult, err error, elapsed time.Duration) string { - if err != nil { - return fmt.Sprintf( - "[System Message] Delegation to agent %q failed.\n\nError: %s\n\nStats: runtime %s\n\n"+ - "Handle the task yourself or try a different agent.", - task.TargetAgentKey, err.Error(), elapsed.Round(time.Millisecond)) - } - return fmt.Sprintf( - "[System Message] Delegation to agent %q completed.\n\nResult:\n%s\n\nStats: runtime %s, iterations %d\n\n"+ - "Convert the result above into your normal assistant voice and send that user-facing update now. "+ - "Keep internal details private. Reply ONLY: NO_REPLY if this exact result was already delivered to the user.", - task.TargetAgentKey, result.Content, elapsed.Round(time.Millisecond), result.Iterations) -} diff --git a/internal/tools/delegate_events.go b/internal/tools/delegate_events.go new file mode 100644 index 00000000..bfa557f0 --- /dev/null +++ b/internal/tools/delegate_events.go @@ -0,0 +1,38 @@ +package tools + +import ( + "fmt" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +func (dm *DelegateManager) emitEvent(name string, task *DelegationTask) { + if dm.msgBus == nil { + return + } + dm.msgBus.Broadcast(bus.Event{ + Name: name, + Payload: map[string]string{ + "delegation_id": task.ID, + "source_agent": task.SourceAgentID.String(), + "target_agent": task.TargetAgentKey, + "user_id": task.UserID, + "mode": task.Mode, + }, + }) +} + +func formatDelegateAnnounce(task *DelegationTask, result *DelegateRunResult, err error, elapsed time.Duration) string { + if err != nil { + return fmt.Sprintf( + "[System Message] Delegation to agent %q failed.\n\nError: %s\n\nStats: runtime %s\n\n"+ + "Handle the task yourself or try a different agent.", + task.TargetAgentKey, err.Error(), elapsed.Round(time.Millisecond)) + } + return fmt.Sprintf( + "[System Message] Delegation to agent %q completed.\n\nResult:\n%s\n\nStats: runtime %s, iterations %d\n\n"+ + "Convert the result above into your normal assistant voice and send that user-facing update now. "+ + "Keep internal details private. Reply ONLY: NO_REPLY if this exact result was already delivered to the user.", + task.TargetAgentKey, result.Content, elapsed.Round(time.Millisecond), result.Iterations) +} diff --git a/internal/tools/delegate_policy.go b/internal/tools/delegate_policy.go new file mode 100644 index 00000000..4b22e7a4 --- /dev/null +++ b/internal/tools/delegate_policy.go @@ -0,0 +1,156 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +func checkUserPermission(settings json.RawMessage, userID string) error { + if len(settings) == 0 || string(settings) == "{}" { + return nil + } + var s linkSettings + if json.Unmarshal(settings, &s) != nil { + return nil // malformed = fail open + } + for _, denied := range s.UserDeny { + if denied == userID { + return fmt.Errorf("you are not authorized to use this delegation link") + } + } + if len(s.UserAllow) > 0 { + for _, allowed := range s.UserAllow { + if allowed == userID { + return nil + } + } + return fmt.Errorf("you are not authorized to use this delegation link") + } + return nil +} + +func parseMaxDelegationLoad(otherConfig json.RawMessage) int { + if len(otherConfig) == 0 { + return defaultMaxDelegationLoad + } + var cfg struct { + MaxDelegationLoad int `json:"max_delegation_load"` + } + if json.Unmarshal(otherConfig, &cfg) != nil || cfg.MaxDelegationLoad <= 0 { + return defaultMaxDelegationLoad + } + return cfg.MaxDelegationLoad +} + +func parseQualityGates(otherConfig json.RawMessage) []hooks.HookConfig { + if len(otherConfig) == 0 { + return nil + } + var cfg struct { + QualityGates []hooks.HookConfig `json:"quality_gates"` + } + if json.Unmarshal(otherConfig, &cfg) != nil { + return nil + } + return cfg.QualityGates +} + +// applyQualityGates evaluates quality gates on a delegation result. +// Returns the (possibly revised) result. If a blocking gate fails after all retries, +// returns the last result anyway with a logged warning (does not hard-fail the delegation). +// Only returns error on catastrophic failures (e.g. context cancelled). +func (dm *DelegateManager) applyQualityGates( + ctx context.Context, task *DelegationTask, opts DelegateOpts, + result *DelegateRunResult, +) (*DelegateRunResult, error) { + if dm.hookEngine == nil || hooks.SkipHooksFromContext(ctx) { + return result, nil + } + + sourceAgent, err := dm.agentStore.GetByID(ctx, task.SourceAgentID) + if err != nil || sourceAgent == nil { + return result, nil + } + + gates := parseQualityGates(sourceAgent.OtherConfig) + if len(gates) == 0 { + return result, nil + } + + hctx := hooks.HookContext{ + Event: "delegation.completed", + SourceAgentKey: task.SourceAgentKey, + TargetAgentKey: task.TargetAgentKey, + UserID: task.UserID, + Content: result.Content, + Task: opts.Task, + } + + for _, gate := range gates { + if gate.Event != "delegation.completed" { + continue + } + + currentResult := result + retries := gate.MaxRetries + + for attempt := 0; attempt <= retries; attempt++ { + hctx.Content = currentResult.Content + + hookResult, evalErr := dm.hookEngine.EvaluateSingleHook(ctx, gate, hctx) + if evalErr != nil { + slog.Warn("quality_gate: evaluator error, skipping", + "type", gate.Type, "delegation", task.ID, "error", evalErr) + break + } + + if hookResult.Passed { + result = currentResult + break + } + + // Gate failed + if !gate.BlockOnFailure { + slog.Warn("quality_gate: non-blocking gate failed", + "type", gate.Type, "delegation", task.ID) + break + } + + if attempt >= retries { + slog.Warn("quality_gate: max retries exceeded, accepting result", + "type", gate.Type, "delegation", task.ID, "retries", retries) + result = currentResult + break + } + + // Retry: re-run target agent with feedback + slog.Info("quality_gate: retrying delegation", + "type", gate.Type, "delegation", task.ID, + "attempt", attempt+1, "max_retries", retries) + + feedbackMsg := fmt.Sprintf( + "[Quality Gate Feedback — Retry %d/%d]\n"+ + "Your previous output did not pass quality review.\n\n"+ + "Feedback: %s\n\n"+ + "Original task: %s\n\n"+ + "Please revise your output addressing the feedback.", + attempt+1, retries, hookResult.Feedback, opts.Task) + + rerunResult, rerunErr := dm.runAgent(ctx, opts.TargetAgentKey, dm.buildRunRequest(task, feedbackMsg)) + if rerunErr != nil { + slog.Warn("quality_gate: retry run failed, accepting previous result", + "delegation", task.ID, "error", rerunErr) + result = currentResult + break + } + currentResult = rerunResult + result = currentResult + } + } + + return result, nil +} diff --git a/internal/tools/delegate_state.go b/internal/tools/delegate_state.go new file mode 100644 index 00000000..9da4cd6c --- /dev/null +++ b/internal/tools/delegate_state.go @@ -0,0 +1,160 @@ +package tools + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// Cancel cancels a running delegation by ID. +func (dm *DelegateManager) Cancel(delegationID string) bool { + val, ok := dm.active.Load(delegationID) + if !ok { + return false + } + task := val.(*DelegationTask) + if task.cancelFunc != nil { + task.cancelFunc() + } + task.Status = "cancelled" + now := time.Now() + task.CompletedAt = &now + dm.active.Delete(delegationID) + dm.emitEvent("delegation.cancelled", task) + slog.Info("delegation cancelled", "id", delegationID, "target", task.TargetAgentKey) + return true +} + +// ListActive returns all active delegations for a source agent. +func (dm *DelegateManager) ListActive(sourceAgentID uuid.UUID) []*DelegationTask { + var tasks []*DelegationTask + dm.active.Range(func(_, val any) bool { + t := val.(*DelegationTask) + if t.SourceAgentID == sourceAgentID && t.Status == "running" { + tasks = append(tasks, t) + } + return true + }) + return tasks +} + +// ActiveCountForLink counts running delegations for a specific source→target pair. +func (dm *DelegateManager) ActiveCountForLink(sourceID, targetID uuid.UUID) int { + count := 0 + dm.active.Range(func(_, val any) bool { + t := val.(*DelegationTask) + if t.SourceAgentID == sourceID && t.TargetAgentID == targetID && t.Status == "running" { + count++ + } + return true + }) + return count +} + +// ActiveCountForTarget counts running delegations targeting a specific agent from all sources. +func (dm *DelegateManager) ActiveCountForTarget(targetID uuid.UUID) int { + count := 0 + dm.active.Range(func(_, val any) bool { + t := val.(*DelegationTask) + if t.TargetAgentID == targetID && t.Status == "running" { + count++ + } + return true + }) + return count +} + +// trackCompleted records a delegate session key for deferred cleanup. +func (dm *DelegateManager) trackCompleted(task *DelegationTask) { + if dm.sessionStore == nil { + return + } + dm.completedMu.Lock() + dm.completedSessions = append(dm.completedSessions, task.SessionKey) + dm.completedMu.Unlock() +} + +// flushCompletedSessions deletes all tracked delegate sessions. +func (dm *DelegateManager) flushCompletedSessions() { + if dm.sessionStore == nil { + return + } + dm.completedMu.Lock() + sessions := dm.completedSessions + dm.completedSessions = nil + dm.completedMu.Unlock() + + for _, key := range sessions { + if err := dm.sessionStore.Delete(key); err != nil { + slog.Warn("delegate: session cleanup failed", "session", key, "error", err) + } + } + if len(sessions) > 0 { + slog.Info("delegate: cleaned up sessions", "count", len(sessions)) + } +} + +// autoCompleteTeamTask attempts to claim+complete the associated team task. +// Called after a delegation finishes successfully. Errors are logged but not fatal. +// On success, flushes all tracked delegate sessions (task done = context no longer needed). +func (dm *DelegateManager) autoCompleteTeamTask(task *DelegationTask, resultContent string) { + if dm.teamStore == nil || task.TeamTaskID == uuid.Nil { + return + } + _ = dm.teamStore.ClaimTask(context.Background(), task.TeamTaskID, task.TargetAgentID) + if err := dm.teamStore.CompleteTask(context.Background(), task.TeamTaskID, resultContent); err != nil { + slog.Warn("delegate: failed to auto-complete team task", + "task_id", task.TeamTaskID, "delegation_id", task.ID, "error", err) + } else { + slog.Info("delegate: auto-completed team task", + "task_id", task.TeamTaskID, "delegation_id", task.ID) + // Task done — flush delegate sessions + dm.flushCompletedSessions() + } +} + +// saveDelegationHistory persists a delegation record to the database. +// Called after delegation completes (success, fail, or cancel). Errors are logged, not fatal. +func (dm *DelegateManager) saveDelegationHistory(task *DelegationTask, resultContent string, delegateErr error, duration time.Duration) { + if dm.teamStore == nil { + return + } + + record := &store.DelegationHistoryData{ + SourceAgentID: task.SourceAgentID, + TargetAgentID: task.TargetAgentID, + UserID: task.UserID, + Task: task.Task, + Mode: task.Mode, + Iterations: 0, + DurationMS: int(duration.Milliseconds()), + } + + if task.TeamTaskID != uuid.Nil { + record.TeamTaskID = &task.TeamTaskID + } + if task.OriginTraceID != uuid.Nil { + record.TraceID = &task.OriginTraceID + } + + now := time.Now() + record.CompletedAt = &now + + if delegateErr != nil { + record.Status = "failed" + errStr := delegateErr.Error() + record.Error = &errStr + } else { + record.Status = "completed" + record.Result = &resultContent + } + + if err := dm.teamStore.SaveDelegationHistory(context.Background(), record); err != nil { + slog.Warn("delegate: failed to save delegation history", + "delegation_id", task.ID, "error", err) + } +} diff --git a/internal/tools/sessions.go b/internal/tools/sessions.go index 1458c5c6..1b1b870b 100644 --- a/internal/tools/sessions.go +++ b/internal/tools/sessions.go @@ -6,9 +6,7 @@ import ( "fmt" "strings" "time" - "unicode/utf8" - "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -176,227 +174,3 @@ func (t *SessionStatusTool) Execute(ctx context.Context, args map[string]interfa return SilentResult(strings.Join(lines, "\n")) } - -// ============================================================ -// sessions_history -// ============================================================ - -const ( - historyMaxCharsPerMessage = 4000 - historyMaxTotalBytes = 80 * 1024 -) - -type SessionsHistoryTool struct { - sessions store.SessionStore -} - -func NewSessionsHistoryTool() *SessionsHistoryTool { return &SessionsHistoryTool{} } - -func (t *SessionsHistoryTool) SetSessionStore(s store.SessionStore) { t.sessions = s } - -func (t *SessionsHistoryTool) Name() string { return "sessions_history" } -func (t *SessionsHistoryTool) Description() string { - return "Fetch message history for a session." -} - -func (t *SessionsHistoryTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "session_key": map[string]interface{}{ - "type": "string", - "description": "Session key to fetch history from", - }, - "limit": map[string]interface{}{ - "type": "number", - "description": "Max messages to return (default 20)", - }, - "include_tools": map[string]interface{}{ - "type": "boolean", - "description": "Include tool call/result messages (default false)", - }, - }, - "required": []string{"session_key"}, - } -} - -func (t *SessionsHistoryTool) Execute(ctx context.Context, args map[string]interface{}) *Result { - if t.sessions == nil { - return ErrorResult("session store not available") - } - - sessionKey, _ := args["session_key"].(string) - if sessionKey == "" { - return ErrorResult("session_key is required") - } - - limit := 20 - if v, ok := args["limit"].(float64); ok && int(v) > 0 { - limit = int(v) - } - - includeTools, _ := args["include_tools"].(bool) - - // Security: validate session belongs to current agent - agentID := resolveAgentIDString(ctx) - if agentID != "" && !strings.HasPrefix(sessionKey, "agent:"+agentID+":") { - return ErrorResult("access denied: session belongs to a different agent") - } - - history := t.sessions.GetHistory(sessionKey) - if history == nil { - return SilentResult(`{"session_key":"` + sessionKey + `","messages":[],"count":0}`) - } - - // Filter tool messages - type msgEntry struct { - Role string `json:"role"` - Content string `json:"content"` - } - var entries []msgEntry - for _, m := range history { - if !includeTools && m.Role == "tool" { - continue - } - // Skip assistant messages that are only tool calls with no text - if !includeTools && m.Role == "assistant" && len(m.ToolCalls) > 0 && strings.TrimSpace(m.Content) == "" { - continue - } - - content := m.Content - // Truncate per-message - if utf8.RuneCountInString(content) > historyMaxCharsPerMessage { - runes := []rune(content) - content = string(runes[:historyMaxCharsPerMessage]) + "... [truncated]" - } - - entries = append(entries, msgEntry{Role: m.Role, Content: content}) - } - - // Keep last N - if len(entries) > limit { - entries = entries[len(entries)-limit:] - } - - out, _ := json.Marshal(map[string]interface{}{ - "session_key": sessionKey, - "messages": entries, - "count": len(entries), - }) - - // Cap total bytes - if len(out) > historyMaxTotalBytes { - return SilentResult(fmt.Sprintf( - `{"session_key":"%s","error":"history too large (%d bytes), use smaller limit","count":%d}`, - sessionKey, len(out), len(entries), - )) - } - - return SilentResult(string(out)) -} - -// ============================================================ -// sessions_send -// ============================================================ - -type SessionsSendTool struct { - sessions store.SessionStore - msgBus *bus.MessageBus -} - -func NewSessionsSendTool() *SessionsSendTool { return &SessionsSendTool{} } - -func (t *SessionsSendTool) SetSessionStore(s store.SessionStore) { t.sessions = s } -func (t *SessionsSendTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b } - -func (t *SessionsSendTool) Name() string { return "sessions_send" } -func (t *SessionsSendTool) Description() string { - return "Send a message into another session. Use session_key or label to identify the target." -} - -func (t *SessionsSendTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "session_key": map[string]interface{}{ - "type": "string", - "description": "Target session key", - }, - "label": map[string]interface{}{ - "type": "string", - "description": "Target session label (alternative to session_key)", - }, - "message": map[string]interface{}{ - "type": "string", - "description": "Message to send", - }, - }, - "required": []string{"message"}, - } -} - -func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]interface{}) *Result { - if t.sessions == nil { - return ErrorResult("session store not available") - } - if t.msgBus == nil { - return ErrorResult("message bus not available") - } - - sessionKey, _ := args["session_key"].(string) - label, _ := args["label"].(string) - message, _ := args["message"].(string) - - if message == "" { - return ErrorResult("message is required") - } - if sessionKey == "" && label == "" { - return ErrorResult("either session_key or label is required") - } - - agentID := resolveAgentIDString(ctx) - - // Resolve by label if needed - if sessionKey == "" && label != "" { - sessions := t.sessions.List(agentID) - for _, s := range sessions { - // Check if label matches by loading session data - data := t.sessions.GetOrCreate(s.Key) - if data.Label == label { - sessionKey = s.Key - break - } - } - if sessionKey == "" { - return ErrorResult(fmt.Sprintf("no session found with label: %s", label)) - } - } - - // Security: validate target session belongs to same agent - if agentID != "" && !strings.HasPrefix(sessionKey, "agent:"+agentID+":") { - return ErrorResult("access denied: target session belongs to a different agent") - } - - // Publish as an inbound message (same mechanism as channels) - t.msgBus.PublishInbound(bus.InboundMessage{ - Channel: "system", - SenderID: "session_send_tool", - ChatID: sessionKey, - Content: message, - PeerKind: "direct", - }) - - return SilentResult(fmt.Sprintf(`{"status":"accepted","session_key":"%s"}`, sessionKey)) -} - -// ============================================================ -// helpers -// ============================================================ - -func resolveAgentIDString(ctx context.Context) string { - id := store.AgentIDFromContext(ctx) - if id.String() == "00000000-0000-0000-0000-000000000000" { - return "" // standalone mode - } - return id.String() -} diff --git a/internal/tools/sessions_history.go b/internal/tools/sessions_history.go new file mode 100644 index 00000000..c09ab6c8 --- /dev/null +++ b/internal/tools/sessions_history.go @@ -0,0 +1,129 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ============================================================ +// sessions_history +// ============================================================ + +const ( + historyMaxCharsPerMessage = 4000 + historyMaxTotalBytes = 80 * 1024 +) + +type SessionsHistoryTool struct { + sessions store.SessionStore +} + +func NewSessionsHistoryTool() *SessionsHistoryTool { return &SessionsHistoryTool{} } + +func (t *SessionsHistoryTool) SetSessionStore(s store.SessionStore) { t.sessions = s } + +func (t *SessionsHistoryTool) Name() string { return "sessions_history" } +func (t *SessionsHistoryTool) Description() string { + return "Fetch message history for a session." +} + +func (t *SessionsHistoryTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "session_key": map[string]interface{}{ + "type": "string", + "description": "Session key to fetch history from", + }, + "limit": map[string]interface{}{ + "type": "number", + "description": "Max messages to return (default 20)", + }, + "include_tools": map[string]interface{}{ + "type": "boolean", + "description": "Include tool call/result messages (default false)", + }, + }, + "required": []string{"session_key"}, + } +} + +func (t *SessionsHistoryTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + if t.sessions == nil { + return ErrorResult("session store not available") + } + + sessionKey, _ := args["session_key"].(string) + if sessionKey == "" { + return ErrorResult("session_key is required") + } + + limit := 20 + if v, ok := args["limit"].(float64); ok && int(v) > 0 { + limit = int(v) + } + + includeTools, _ := args["include_tools"].(bool) + + // Security: validate session belongs to current agent + agentID := resolveAgentIDString(ctx) + if agentID != "" && !strings.HasPrefix(sessionKey, "agent:"+agentID+":") { + return ErrorResult("access denied: session belongs to a different agent") + } + + history := t.sessions.GetHistory(sessionKey) + if history == nil { + return SilentResult(`{"session_key":"` + sessionKey + `","messages":[],"count":0}`) + } + + // Filter tool messages + type msgEntry struct { + Role string `json:"role"` + Content string `json:"content"` + } + var entries []msgEntry + for _, m := range history { + if !includeTools && m.Role == "tool" { + continue + } + // Skip assistant messages that are only tool calls with no text + if !includeTools && m.Role == "assistant" && len(m.ToolCalls) > 0 && strings.TrimSpace(m.Content) == "" { + continue + } + + content := m.Content + // Truncate per-message + if utf8.RuneCountInString(content) > historyMaxCharsPerMessage { + runes := []rune(content) + content = string(runes[:historyMaxCharsPerMessage]) + "... [truncated]" + } + + entries = append(entries, msgEntry{Role: m.Role, Content: content}) + } + + // Keep last N + if len(entries) > limit { + entries = entries[len(entries)-limit:] + } + + out, _ := json.Marshal(map[string]interface{}{ + "session_key": sessionKey, + "messages": entries, + "count": len(entries), + }) + + // Cap total bytes + if len(out) > historyMaxTotalBytes { + return SilentResult(fmt.Sprintf( + `{"session_key":"%s","error":"history too large (%d bytes), use smaller limit","count":%d}`, + sessionKey, len(out), len(entries), + )) + } + + return SilentResult(string(out)) +} diff --git a/internal/tools/sessions_send.go b/internal/tools/sessions_send.go new file mode 100644 index 00000000..7c62590f --- /dev/null +++ b/internal/tools/sessions_send.go @@ -0,0 +1,116 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ============================================================ +// sessions_send +// ============================================================ + +type SessionsSendTool struct { + sessions store.SessionStore + msgBus *bus.MessageBus +} + +func NewSessionsSendTool() *SessionsSendTool { return &SessionsSendTool{} } + +func (t *SessionsSendTool) SetSessionStore(s store.SessionStore) { t.sessions = s } +func (t *SessionsSendTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b } + +func (t *SessionsSendTool) Name() string { return "sessions_send" } +func (t *SessionsSendTool) Description() string { + return "Send a message into another session. Use session_key or label to identify the target." +} + +func (t *SessionsSendTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "session_key": map[string]interface{}{ + "type": "string", + "description": "Target session key", + }, + "label": map[string]interface{}{ + "type": "string", + "description": "Target session label (alternative to session_key)", + }, + "message": map[string]interface{}{ + "type": "string", + "description": "Message to send", + }, + }, + "required": []string{"message"}, + } +} + +func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + if t.sessions == nil { + return ErrorResult("session store not available") + } + if t.msgBus == nil { + return ErrorResult("message bus not available") + } + + sessionKey, _ := args["session_key"].(string) + label, _ := args["label"].(string) + message, _ := args["message"].(string) + + if message == "" { + return ErrorResult("message is required") + } + if sessionKey == "" && label == "" { + return ErrorResult("either session_key or label is required") + } + + agentID := resolveAgentIDString(ctx) + + // Resolve by label if needed + if sessionKey == "" && label != "" { + sessions := t.sessions.List(agentID) + for _, s := range sessions { + // Check if label matches by loading session data + data := t.sessions.GetOrCreate(s.Key) + if data.Label == label { + sessionKey = s.Key + break + } + } + if sessionKey == "" { + return ErrorResult(fmt.Sprintf("no session found with label: %s", label)) + } + } + + // Security: validate target session belongs to same agent + if agentID != "" && !strings.HasPrefix(sessionKey, "agent:"+agentID+":") { + return ErrorResult("access denied: target session belongs to a different agent") + } + + // Publish as an inbound message (same mechanism as channels) + t.msgBus.PublishInbound(bus.InboundMessage{ + Channel: "system", + SenderID: "session_send_tool", + ChatID: sessionKey, + Content: message, + PeerKind: "direct", + }) + + return SilentResult(fmt.Sprintf(`{"status":"accepted","session_key":"%s"}`, sessionKey)) +} + +// ============================================================ +// helpers +// ============================================================ + +func resolveAgentIDString(ctx context.Context) string { + id := store.AgentIDFromContext(ctx) + if id.String() == "00000000-0000-0000-0000-000000000000" { + return "" // standalone mode + } + return id.String() +} diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index a352b2b9..276e3977 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -11,7 +11,6 @@ package tools import ( "context" - "encoding/json" "fmt" "log/slog" "sync" @@ -34,17 +33,6 @@ type SubagentConfig struct { Model string // model override for subagents (empty = inherit) } -// DefaultSubagentConfig returns sensible defaults matching OpenClaw TS spec. -// TS sources: agent-limits.ts, sessions-spawn-tool.ts, subagent-registry.ts. -func DefaultSubagentConfig() SubagentConfig { - return SubagentConfig{ - MaxConcurrent: 8, // TS: DEFAULT_SUBAGENT_MAX_CONCURRENT = 8 - MaxSpawnDepth: 1, // TS: maxSpawnDepth ?? 1 - MaxChildrenPerAgent: 5, // TS: maxChildrenPerAgent ?? 5 - ArchiveAfterMinutes: 60, // TS: archiveAfterMinutes ?? 60 - } -} - // Subagent task status constants. const ( TaskStatusRunning = "running" @@ -272,302 +260,3 @@ func (sm *SubagentManager) RunSync( return subTask.Result, iterations, nil } - -// runTask executes the subagent in a goroutine. -func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { - iterations := sm.executeTask(ctx, task) - - // Announce result to parent via bus (matching TS subagent-announce.ts pattern). - // The announce goes through the parent agent's session so the agent can - // reformulate the result for the user. - if sm.msgBus != nil && task.OriginChannel != "" { - elapsed := time.Since(time.UnixMilli(task.CreatedAt)) - - item := AnnounceQueueItem{ - SubagentID: task.ID, - Label: task.Label, - Status: task.Status, - Result: task.Result, - Runtime: elapsed, - Iterations: iterations, - } - meta := AnnounceMetadata{ - OriginChannel: task.OriginChannel, - OriginChatID: task.OriginChatID, - OriginPeerKind: task.OriginPeerKind, - OriginUserID: task.OriginUserID, - ParentAgent: task.ParentID, - OriginTraceID: task.OriginTraceID.String(), - OriginRootSpanID: task.OriginRootSpanID.String(), - } - - if sm.announceQueue != nil { - // Use batched announce queue (matching TS debounce pattern) - sessionKey := fmt.Sprintf("announce:%s:%s", task.ParentID, task.OriginChatID) - sm.announceQueue.Enqueue(sessionKey, item, meta) - } else { - // Direct publish (no batching) - remainingActive := sm.CountRunningForParent(task.ParentID) - announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, remainingActive) - - sm.msgBus.PublishInbound(bus.InboundMessage{ - Channel: "system", - SenderID: fmt.Sprintf("subagent:%s", task.ID), - ChatID: task.OriginChatID, - Content: announceContent, - UserID: task.OriginUserID, - Metadata: map[string]string{ - "origin_channel": task.OriginChannel, - "origin_peer_kind": task.OriginPeerKind, - "parent_agent": task.ParentID, - "subagent_id": task.ID, - "subagent_label": task.Label, - "origin_trace_id": task.OriginTraceID.String(), - "origin_root_span_id": task.OriginRootSpanID.String(), - }, - }) - } - } - - // Call completion callback - if callback != nil { - result := NewResult(fmt.Sprintf("Subagent '%s' completed in %d iterations.\n\nResult:\n%s", - task.Label, iterations, task.Result)) - callback(ctx, result) - } -} - -// executeTask runs the LLM tool loop for a subagent. Returns iteration count. -func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) int { - // Tracing: generate a root span ID for this subagent execution. - // LLM/tool spans will nest under this root span via parent_span_id. - // The root span itself links to the parent agent's root span (from ctx). - subRootSpanID := store.GenNewID() - taskStart := time.Now().UTC() - - // Use a detached context for tracing so spans are emitted even if parent ctx is cancelled. - // We copy tracing values but remove the cancellation chain. - traceCtx := context.Background() - if collector := tracing.CollectorFromContext(ctx); collector != nil { - traceCtx = tracing.WithCollector(traceCtx, collector) - traceCtx = tracing.WithTraceID(traceCtx, tracing.TraceIDFromContext(ctx)) - // Keep original parent_span_id (parent agent's root span) for the subagent root span. - traceCtx = tracing.WithParentSpanID(traceCtx, tracing.ParentSpanIDFromContext(ctx)) - } - - // subCtx overrides parent_span_id so child spans nest under subRootSpanID. - // traceCtx retains the original parent_span_id for the root subagent span. - subTraceCtx := tracing.WithParentSpanID(traceCtx, subRootSpanID) - - var model string - var finalContent string - iteration := 0 - - defer func() { - sm.mu.Lock() - task.CompletedAt = time.Now().UnixMilli() - sm.mu.Unlock() - - // Always emit root subagent span on exit (uses traceCtx which is never cancelled). - sm.emitSubagentSpan(traceCtx, subRootSpanID, taskStart, task, model, finalContent) - slog.Debug("subagent tracing: root span emitted", - "id", task.ID, "span_id", subRootSpanID, - "trace_id", tracing.TraceIDFromContext(traceCtx), - "status", task.Status, "iterations", iteration) - - // Schedule auto-archive - if sm.config.ArchiveAfterMinutes > 0 { - go sm.scheduleArchive(task.ID, time.Duration(sm.config.ArchiveAfterMinutes)*time.Minute) - } - }() - - if ctx.Err() != nil { - sm.mu.Lock() - task.Status = TaskStatusCancelled - task.Result = "cancelled before execution" - sm.mu.Unlock() - return 0 - } - - // Build tools for subagent (no spawn/subagent tools to prevent recursion) - toolsReg := sm.createTools() - sm.applyDenyList(toolsReg, task.Depth) - - // Determine model (cascading priority matching TS sessions-spawn-tool.ts): - // 1. Per-task model override (highest) - // 2. SubagentConfig.Model (global subagent override) - // 3. SubagentManager default model (inherited from parent) - model = sm.model - if sm.config.Model != "" { - model = sm.config.Model - } - if task.Model != "" { - model = task.Model - } - - // Build subagent system prompt (matching TS buildSubagentSystemPrompt pattern). - systemPrompt := sm.buildSubagentSystemPrompt(task) - - messages := []providers.Message{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: task.Task}, - } - - // Run LLM iteration loop (similar to agent loop but simplified) - maxIterations := 20 - - for iteration < maxIterations { - iteration++ - - if ctx.Err() != nil { - sm.mu.Lock() - task.Status = TaskStatusCancelled - task.Result = "cancelled during execution" - sm.mu.Unlock() - return iteration - } - - chatReq := providers.ChatRequest{ - Messages: messages, - Tools: toolsReg.ProviderDefs(), - Model: model, - Options: map[string]interface{}{ - "max_tokens": 4096, - "temperature": 0.5, - }, - } - - llmStart := time.Now().UTC() - resp, err := sm.provider.Chat(ctx, chatReq) - sm.emitLLMSpan(subTraceCtx, llmStart, iteration, model, messages, resp, err) - - if err != nil { - sm.mu.Lock() - task.Status = TaskStatusFailed - task.Result = fmt.Sprintf("LLM error at iteration %d: %v", iteration, err) - sm.mu.Unlock() - slog.Warn("subagent LLM error", "id", task.ID, "iteration", iteration, "error", err) - return iteration - } - - // No tool calls → done - if len(resp.ToolCalls) == 0 { - finalContent = resp.Content - break - } - - // Build assistant message - assistantMsg := providers.Message{ - Role: "assistant", - Content: resp.Content, - ToolCalls: resp.ToolCalls, - } - messages = append(messages, assistantMsg) - - // Execute tools - for _, tc := range resp.ToolCalls { - slog.Debug("subagent tool call", "id", task.ID, "tool", tc.Name) - - toolStart := time.Now().UTC() - result := toolsReg.Execute(ctx, tc.Name, tc.Arguments) - - argsJSON, _ := json.Marshal(tc.Arguments) - sm.emitToolSpan(subTraceCtx, toolStart, tc.Name, tc.ID, string(argsJSON), result.ForLLM, result.IsError) - - messages = append(messages, providers.Message{ - Role: "tool", - Content: result.ForLLM, - ToolCallID: tc.ID, - }) - } - } - - sm.mu.Lock() - if finalContent == "" { - finalContent = "Task completed but no final response was generated." - } - task.Status = TaskStatusCompleted - task.Result = finalContent - sm.mu.Unlock() - - slog.Info("subagent completed", "id", task.ID, "iterations", iteration) - return iteration -} - -// applyDenyList removes denied tools from the registry based on depth. -func (sm *SubagentManager) applyDenyList(reg *Registry, depth int) { - // Always deny - for _, name := range SubagentDenyAlways { - reg.Unregister(name) - } - - // Leaf deny (at max depth) - if depth >= sm.config.MaxSpawnDepth { - for _, name := range SubagentDenyLeaf { - reg.Unregister(name) - } - } -} - -// buildSubagentSystemPrompt constructs the system prompt for a subagent, -// matching the TS buildSubagentSystemPrompt pattern from subagent-announce.ts. -func (sm *SubagentManager) buildSubagentSystemPrompt(task *SubagentTask) string { - parentLabel := "main agent" - if task.Depth >= 2 { - parentLabel = "parent orchestrator" - } - - canSpawn := task.Depth < sm.config.MaxSpawnDepth - - prompt := fmt.Sprintf(`# Subagent Context - -You are a **subagent** spawned by the %s for a specific task. - -## Your Role -- You were created to handle: %s -- Complete this task. That is your entire purpose. -- You are NOT the %s. Do not try to be. - -## Rules -1. **Stay focused** — Do your assigned task, nothing else. -2. **Complete the task** — Your final message will be automatically reported to the %s. -3. **Never ask for clarification** — Work with what you have. If asked to create content, generate it yourself. -4. **Be ephemeral** — You may be terminated after task completion. That is fine. - -## Output Format -Your final response IS the deliverable — it will be forwarded to the user. -- If asked to create content (posts, articles, messages, etc.), output the FULL content directly. Do NOT describe what you wrote — just write it. -- Do NOT say "I wrote a post about..." or "Here is what I created...". Output the content itself as your response. -- If the task is research or analysis, provide the complete findings. -- The %s will receive your exact final response, so make it user-ready. - -## What You Do NOT Do -- NO user conversations (that is the %s's job) -- NO external messages unless explicitly tasked -- NO cron jobs or persistent state -- NO pretending to be the %s`, - parentLabel, task.Task, - parentLabel, parentLabel, parentLabel, parentLabel, parentLabel) - - if canSpawn { - prompt += ` - -## Sub-Agent Spawning -You CAN spawn your own sub-agents for parallel or complex work using the spawn tool. -Your sub-agents will announce their results back to you automatically (not to the main agent). -Coordinate their work and synthesize results before reporting back.` - } else if task.Depth >= 2 { - prompt += ` - -## Sub-Agent Spawning -You are a leaf worker and CANNOT spawn further sub-agents. Focus on your assigned task.` - } - - prompt += fmt.Sprintf(` - -## Session Context -- Label: %s -- Depth: %d / %d`, task.Label, task.Depth, sm.config.MaxSpawnDepth) - - return prompt -} diff --git a/internal/tools/subagent_config.go b/internal/tools/subagent_config.go new file mode 100644 index 00000000..23b0d277 --- /dev/null +++ b/internal/tools/subagent_config.go @@ -0,0 +1,92 @@ +package tools + +import "fmt" + +// DefaultSubagentConfig returns sensible defaults matching OpenClaw TS spec. +// TS sources: agent-limits.ts, sessions-spawn-tool.ts, subagent-registry.ts. +func DefaultSubagentConfig() SubagentConfig { + return SubagentConfig{ + MaxConcurrent: 8, // TS: DEFAULT_SUBAGENT_MAX_CONCURRENT = 8 + MaxSpawnDepth: 1, // TS: maxSpawnDepth ?? 1 + MaxChildrenPerAgent: 5, // TS: maxChildrenPerAgent ?? 5 + ArchiveAfterMinutes: 60, // TS: archiveAfterMinutes ?? 60 + } +} + +// applyDenyList removes denied tools from the registry based on depth. +func (sm *SubagentManager) applyDenyList(reg *Registry, depth int) { + // Always deny + for _, name := range SubagentDenyAlways { + reg.Unregister(name) + } + + // Leaf deny (at max depth) + if depth >= sm.config.MaxSpawnDepth { + for _, name := range SubagentDenyLeaf { + reg.Unregister(name) + } + } +} + +// buildSubagentSystemPrompt constructs the system prompt for a subagent, +// matching the TS buildSubagentSystemPrompt pattern from subagent-announce.ts. +func (sm *SubagentManager) buildSubagentSystemPrompt(task *SubagentTask) string { + parentLabel := "main agent" + if task.Depth >= 2 { + parentLabel = "parent orchestrator" + } + + canSpawn := task.Depth < sm.config.MaxSpawnDepth + + prompt := fmt.Sprintf(`# Subagent Context + +You are a **subagent** spawned by the %s for a specific task. + +## Your Role +- You were created to handle: %s +- Complete this task. That is your entire purpose. +- You are NOT the %s. Do not try to be. + +## Rules +1. **Stay focused** — Do your assigned task, nothing else. +2. **Complete the task** — Your final message will be automatically reported to the %s. +3. **Never ask for clarification** — Work with what you have. If asked to create content, generate it yourself. +4. **Be ephemeral** — You may be terminated after task completion. That is fine. + +## Output Format +Your final response IS the deliverable — it will be forwarded to the user. +- If asked to create content (posts, articles, messages, etc.), output the FULL content directly. Do NOT describe what you wrote — just write it. +- Do NOT say "I wrote a post about..." or "Here is what I created...". Output the content itself as your response. +- If the task is research or analysis, provide the complete findings. +- The %s will receive your exact final response, so make it user-ready. + +## What You Do NOT Do +- NO user conversations (that is the %s's job) +- NO external messages unless explicitly tasked +- NO cron jobs or persistent state +- NO pretending to be the %s`, + parentLabel, task.Task, + parentLabel, parentLabel, parentLabel, parentLabel, parentLabel) + + if canSpawn { + prompt += ` + +## Sub-Agent Spawning +You CAN spawn your own sub-agents for parallel or complex work using the spawn tool. +Your sub-agents will announce their results back to you automatically (not to the main agent). +Coordinate their work and synthesize results before reporting back.` + } else if task.Depth >= 2 { + prompt += ` + +## Sub-Agent Spawning +You are a leaf worker and CANNOT spawn further sub-agents. Focus on your assigned task.` + } + + prompt += fmt.Sprintf(` + +## Session Context +- Label: %s +- Depth: %d / %d`, task.Label, task.Depth, sm.config.MaxSpawnDepth) + + return prompt +} diff --git a/internal/tools/subagent_exec.go b/internal/tools/subagent_exec.go new file mode 100644 index 00000000..8e84f18e --- /dev/null +++ b/internal/tools/subagent_exec.go @@ -0,0 +1,235 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// runTask executes the subagent in a goroutine. +func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { + iterations := sm.executeTask(ctx, task) + + // Announce result to parent via bus (matching TS subagent-announce.ts pattern). + // The announce goes through the parent agent's session so the agent can + // reformulate the result for the user. + if sm.msgBus != nil && task.OriginChannel != "" { + elapsed := time.Since(time.UnixMilli(task.CreatedAt)) + + item := AnnounceQueueItem{ + SubagentID: task.ID, + Label: task.Label, + Status: task.Status, + Result: task.Result, + Runtime: elapsed, + Iterations: iterations, + } + meta := AnnounceMetadata{ + OriginChannel: task.OriginChannel, + OriginChatID: task.OriginChatID, + OriginPeerKind: task.OriginPeerKind, + OriginUserID: task.OriginUserID, + ParentAgent: task.ParentID, + OriginTraceID: task.OriginTraceID.String(), + OriginRootSpanID: task.OriginRootSpanID.String(), + } + + if sm.announceQueue != nil { + // Use batched announce queue (matching TS debounce pattern) + sessionKey := fmt.Sprintf("announce:%s:%s", task.ParentID, task.OriginChatID) + sm.announceQueue.Enqueue(sessionKey, item, meta) + } else { + // Direct publish (no batching) + remainingActive := sm.CountRunningForParent(task.ParentID) + announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, remainingActive) + + sm.msgBus.PublishInbound(bus.InboundMessage{ + Channel: "system", + SenderID: fmt.Sprintf("subagent:%s", task.ID), + ChatID: task.OriginChatID, + Content: announceContent, + UserID: task.OriginUserID, + Metadata: map[string]string{ + "origin_channel": task.OriginChannel, + "origin_peer_kind": task.OriginPeerKind, + "parent_agent": task.ParentID, + "subagent_id": task.ID, + "subagent_label": task.Label, + "origin_trace_id": task.OriginTraceID.String(), + "origin_root_span_id": task.OriginRootSpanID.String(), + }, + }) + } + } + + // Call completion callback + if callback != nil { + result := NewResult(fmt.Sprintf("Subagent '%s' completed in %d iterations.\n\nResult:\n%s", + task.Label, iterations, task.Result)) + callback(ctx, result) + } +} + +// executeTask runs the LLM tool loop for a subagent. Returns iteration count. +func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) int { + // Tracing: generate a root span ID for this subagent execution. + // LLM/tool spans will nest under this root span via parent_span_id. + // The root span itself links to the parent agent's root span (from ctx). + subRootSpanID := store.GenNewID() + taskStart := time.Now().UTC() + + // Use a detached context for tracing so spans are emitted even if parent ctx is cancelled. + // We copy tracing values but remove the cancellation chain. + traceCtx := context.Background() + if collector := tracing.CollectorFromContext(ctx); collector != nil { + traceCtx = tracing.WithCollector(traceCtx, collector) + traceCtx = tracing.WithTraceID(traceCtx, tracing.TraceIDFromContext(ctx)) + // Keep original parent_span_id (parent agent's root span) for the subagent root span. + traceCtx = tracing.WithParentSpanID(traceCtx, tracing.ParentSpanIDFromContext(ctx)) + } + + // subCtx overrides parent_span_id so child spans nest under subRootSpanID. + // traceCtx retains the original parent_span_id for the root subagent span. + subTraceCtx := tracing.WithParentSpanID(traceCtx, subRootSpanID) + + var model string + var finalContent string + iteration := 0 + + defer func() { + sm.mu.Lock() + task.CompletedAt = time.Now().UnixMilli() + sm.mu.Unlock() + + // Always emit root subagent span on exit (uses traceCtx which is never cancelled). + sm.emitSubagentSpan(traceCtx, subRootSpanID, taskStart, task, model, finalContent) + slog.Debug("subagent tracing: root span emitted", + "id", task.ID, "span_id", subRootSpanID, + "trace_id", tracing.TraceIDFromContext(traceCtx), + "status", task.Status, "iterations", iteration) + + // Schedule auto-archive + if sm.config.ArchiveAfterMinutes > 0 { + go sm.scheduleArchive(task.ID, time.Duration(sm.config.ArchiveAfterMinutes)*time.Minute) + } + }() + + if ctx.Err() != nil { + sm.mu.Lock() + task.Status = TaskStatusCancelled + task.Result = "cancelled before execution" + sm.mu.Unlock() + return 0 + } + + // Build tools for subagent (no spawn/subagent tools to prevent recursion) + toolsReg := sm.createTools() + sm.applyDenyList(toolsReg, task.Depth) + + // Determine model (cascading priority matching TS sessions-spawn-tool.ts): + // 1. Per-task model override (highest) + // 2. SubagentConfig.Model (global subagent override) + // 3. SubagentManager default model (inherited from parent) + model = sm.model + if sm.config.Model != "" { + model = sm.config.Model + } + if task.Model != "" { + model = task.Model + } + + // Build subagent system prompt (matching TS buildSubagentSystemPrompt pattern). + systemPrompt := sm.buildSubagentSystemPrompt(task) + + messages := []providers.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: task.Task}, + } + + // Run LLM iteration loop (similar to agent loop but simplified) + maxIterations := 20 + + for iteration < maxIterations { + iteration++ + + if ctx.Err() != nil { + sm.mu.Lock() + task.Status = TaskStatusCancelled + task.Result = "cancelled during execution" + sm.mu.Unlock() + return iteration + } + + chatReq := providers.ChatRequest{ + Messages: messages, + Tools: toolsReg.ProviderDefs(), + Model: model, + Options: map[string]interface{}{ + "max_tokens": 4096, + "temperature": 0.5, + }, + } + + llmStart := time.Now().UTC() + resp, err := sm.provider.Chat(ctx, chatReq) + sm.emitLLMSpan(subTraceCtx, llmStart, iteration, model, messages, resp, err) + + if err != nil { + sm.mu.Lock() + task.Status = TaskStatusFailed + task.Result = fmt.Sprintf("LLM error at iteration %d: %v", iteration, err) + sm.mu.Unlock() + slog.Warn("subagent LLM error", "id", task.ID, "iteration", iteration, "error", err) + return iteration + } + + // No tool calls → done + if len(resp.ToolCalls) == 0 { + finalContent = resp.Content + break + } + + // Build assistant message + assistantMsg := providers.Message{ + Role: "assistant", + Content: resp.Content, + ToolCalls: resp.ToolCalls, + } + messages = append(messages, assistantMsg) + + // Execute tools + for _, tc := range resp.ToolCalls { + slog.Debug("subagent tool call", "id", task.ID, "tool", tc.Name) + + toolStart := time.Now().UTC() + result := toolsReg.Execute(ctx, tc.Name, tc.Arguments) + + argsJSON, _ := json.Marshal(tc.Arguments) + sm.emitToolSpan(subTraceCtx, toolStart, tc.Name, tc.ID, string(argsJSON), result.ForLLM, result.IsError) + + messages = append(messages, providers.Message{ + Role: "tool", + Content: result.ForLLM, + ToolCallID: tc.ID, + }) + } + } + + sm.mu.Lock() + if finalContent == "" { + finalContent = "Task completed but no final response was generated." + } + task.Status = TaskStatusCompleted + task.Result = finalContent + sm.mu.Unlock() + + slog.Info("subagent completed", "id", task.ID, "iterations", iteration) + return iteration +} diff --git a/internal/tools/web_fetch.go b/internal/tools/web_fetch.go index 509c9894..571efb95 100644 --- a/internal/tools/web_fetch.go +++ b/internal/tools/web_fetch.go @@ -2,13 +2,11 @@ package tools import ( "context" - "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" - "regexp" "strings" "time" ) @@ -228,184 +226,3 @@ func (t *WebFetchTool) doFetch(ctx context.Context, rawURL, extractMode string, return sb.String(), nil } - -// extractJSON pretty-prints JSON content. -func extractJSON(body []byte) (string, string) { - var data interface{} - if err := json.Unmarshal(body, &data); err == nil { - formatted, _ := json.MarshalIndent(data, "", " ") - return string(formatted), "json" - } - return string(body), "raw" -} - -// --- HTML extraction utilities --- - -var ( - reScript = regexp.MustCompile(`(?is)`) - reStyle = regexp.MustCompile(`(?is)`) - reComment = regexp.MustCompile(``) - reNav = regexp.MustCompile(`(?is)`) - reFooter = regexp.MustCompile(`(?is)`) - reHeader = regexp.MustCompile(`(?is)`) - reTag = regexp.MustCompile(`<[^>]+>`) - reMultiNL = regexp.MustCompile(`\n{3,}`) - reMultiSP = regexp.MustCompile(`[ \t]{2,}`) - reH1 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reH2 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reH3 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reH4 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reH5 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reH6 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reParagraph = regexp.MustCompile(`(?i)]*>([\s\S]*?)

`) - reBreak = regexp.MustCompile(`(?i)`) - reListItem = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reAnchor = regexp.MustCompile(`(?i)]*href="([^"]*)"[^>]*>([\s\S]*?)`) - rePre = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) - reCode = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) - reStrong = regexp.MustCompile(`(?i)<(?:strong|b)[^>]*>([\s\S]*?)`) - reEm = regexp.MustCompile(`(?i)<(?:em|i)[^>]*>([\s\S]*?)`) - reBlockq = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) - reImg = regexp.MustCompile(`(?i)]*alt="([^"]*)"[^>]*/?>`) -) - -// htmlToMarkdown converts HTML to a markdown-like format. -// Not a full Readability implementation but covers common patterns. -func htmlToMarkdown(html string) string { - // Remove non-content elements - s := reScript.ReplaceAllString(html, "") - s = reStyle.ReplaceAllString(s, "") - s = reComment.ReplaceAllString(s, "") - s = reNav.ReplaceAllString(s, "") - s = reFooter.ReplaceAllString(s, "") - - // Convert headings - s = reH1.ReplaceAllString(s, "\n# $1\n") - s = reH2.ReplaceAllString(s, "\n## $1\n") - s = reH3.ReplaceAllString(s, "\n### $1\n") - s = reH4.ReplaceAllString(s, "\n#### $1\n") - s = reH5.ReplaceAllString(s, "\n##### $1\n") - s = reH6.ReplaceAllString(s, "\n###### $1\n") - - // Pre/code blocks (before stripping other tags) - s = rePre.ReplaceAllString(s, "\n```\n$1\n```\n") - s = reCode.ReplaceAllString(s, "`$1`") - - // Blockquotes - s = reBlockq.ReplaceAllStringFunc(s, func(match string) string { - inner := reBlockq.FindStringSubmatch(match) - if len(inner) < 2 { - return match - } - lines := strings.Split(strings.TrimSpace(inner[1]), "\n") - var quoted []string - for _, l := range lines { - quoted = append(quoted, "> "+strings.TrimSpace(l)) - } - return "\n" + strings.Join(quoted, "\n") + "\n" - }) - - // Links: text → [text](url) - s = reAnchor.ReplaceAllString(s, "[$2]($1)") - - // Images: text → ![text] - s = reImg.ReplaceAllString(s, "![$1]") - - // Bold/italic - s = reStrong.ReplaceAllString(s, "**$1**") - s = reEm.ReplaceAllString(s, "*$1*") - - // Paragraphs and breaks - s = reParagraph.ReplaceAllString(s, "\n$1\n") - s = reBreak.ReplaceAllString(s, "\n") - - // List items - s = reListItem.ReplaceAllString(s, "\n- $1") - - // Strip remaining tags - s = reTag.ReplaceAllString(s, "") - - // Clean up - s = decodeHTMLEntities(s) - s = reMultiNL.ReplaceAllString(s, "\n\n") - s = reMultiSP.ReplaceAllString(s, " ") - - return strings.TrimSpace(s) -} - -// htmlToText extracts plain text from HTML content. -func htmlToText(html string) string { - s := reScript.ReplaceAllString(html, "") - s = reStyle.ReplaceAllString(s, "") - s = reComment.ReplaceAllString(s, "") - s = reNav.ReplaceAllString(s, "") - s = reFooter.ReplaceAllString(s, "") - s = reHeader.ReplaceAllString(s, "") - - // Structural breaks - s = reParagraph.ReplaceAllString(s, "\n$1\n") - s = reBreak.ReplaceAllString(s, "\n") - s = reListItem.ReplaceAllString(s, "\n- $1") - - // Strip all tags - s = reTag.ReplaceAllString(s, "") - - s = decodeHTMLEntities(s) - s = reMultiSP.ReplaceAllString(s, " ") - s = reMultiNL.ReplaceAllString(s, "\n\n") - - // Clean lines - lines := strings.Split(s, "\n") - var clean []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" { - clean = append(clean, line) - } - } - return strings.Join(clean, "\n") -} - -// markdownToText strips markdown formatting for text mode. -func markdownToText(md string) string { - s := md - // Remove headers markers - s = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(s, "") - // Remove bold/italic markers - s = strings.ReplaceAll(s, "**", "") - s = strings.ReplaceAll(s, "__", "") - // Remove inline code - s = regexp.MustCompile("`[^`]+`").ReplaceAllStringFunc(s, func(m string) string { - return strings.Trim(m, "`") - }) - // Remove links: [text](url) → text - s = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(s, "$1") - // Remove images - s = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(s, "$1") - // Clean whitespace - s = reMultiNL.ReplaceAllString(s, "\n\n") - return strings.TrimSpace(s) -} - -// decodeHTMLEntities handles common HTML entities. -func decodeHTMLEntities(s string) string { - replacer := strings.NewReplacer( - "&", "&", - "<", "<", - ">", ">", - """, `"`, - "'", "'", - "'", "'", - " ", " ", - "—", "—", - "–", "–", - "«", "«", - "»", "»", - "•", "•", - "…", "...", - "©", "(c)", - "®", "(R)", - "™", "(TM)", - ) - return replacer.Replace(s) -} diff --git a/internal/tools/web_fetch_convert.go b/internal/tools/web_fetch_convert.go new file mode 100644 index 00000000..04ecddcd --- /dev/null +++ b/internal/tools/web_fetch_convert.go @@ -0,0 +1,188 @@ +package tools + +import ( + "encoding/json" + "regexp" + "strings" +) + +// extractJSON pretty-prints JSON content. +func extractJSON(body []byte) (string, string) { + var data interface{} + if err := json.Unmarshal(body, &data); err == nil { + formatted, _ := json.MarshalIndent(data, "", " ") + return string(formatted), "json" + } + return string(body), "raw" +} + +// --- HTML extraction utilities --- + +var ( + reScript = regexp.MustCompile(`(?is)`) + reStyle = regexp.MustCompile(`(?is)`) + reComment = regexp.MustCompile(``) + reNav = regexp.MustCompile(`(?is)`) + reFooter = regexp.MustCompile(`(?is)`) + reHeader = regexp.MustCompile(`(?is)`) + reTag = regexp.MustCompile(`<[^>]+>`) + reMultiNL = regexp.MustCompile(`\n{3,}`) + reMultiSP = regexp.MustCompile(`[ \t]{2,}`) + reH1 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reH2 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reH3 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reH4 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reH5 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reH6 = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reParagraph = regexp.MustCompile(`(?i)]*>([\s\S]*?)

`) + reBreak = regexp.MustCompile(`(?i)`) + reListItem = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reAnchor = regexp.MustCompile(`(?i)]*href="([^"]*)"[^>]*>([\s\S]*?)`) + rePre = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) + reCode = regexp.MustCompile(`(?i)]*>([\s\S]*?)`) + reStrong = regexp.MustCompile(`(?i)<(?:strong|b)[^>]*>([\s\S]*?)`) + reEm = regexp.MustCompile(`(?i)<(?:em|i)[^>]*>([\s\S]*?)`) + reBlockq = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) + reImg = regexp.MustCompile(`(?i)]*alt="([^"]*)"[^>]*/?>`) +) + +// htmlToMarkdown converts HTML to a markdown-like format. +// Not a full Readability implementation but covers common patterns. +func htmlToMarkdown(html string) string { + // Remove non-content elements + s := reScript.ReplaceAllString(html, "") + s = reStyle.ReplaceAllString(s, "") + s = reComment.ReplaceAllString(s, "") + s = reNav.ReplaceAllString(s, "") + s = reFooter.ReplaceAllString(s, "") + + // Convert headings + s = reH1.ReplaceAllString(s, "\n# $1\n") + s = reH2.ReplaceAllString(s, "\n## $1\n") + s = reH3.ReplaceAllString(s, "\n### $1\n") + s = reH4.ReplaceAllString(s, "\n#### $1\n") + s = reH5.ReplaceAllString(s, "\n##### $1\n") + s = reH6.ReplaceAllString(s, "\n###### $1\n") + + // Pre/code blocks (before stripping other tags) + s = rePre.ReplaceAllString(s, "\n```\n$1\n```\n") + s = reCode.ReplaceAllString(s, "`$1`") + + // Blockquotes + s = reBlockq.ReplaceAllStringFunc(s, func(match string) string { + inner := reBlockq.FindStringSubmatch(match) + if len(inner) < 2 { + return match + } + lines := strings.Split(strings.TrimSpace(inner[1]), "\n") + var quoted []string + for _, l := range lines { + quoted = append(quoted, "> "+strings.TrimSpace(l)) + } + return "\n" + strings.Join(quoted, "\n") + "\n" + }) + + // Links: text → [text](url) + s = reAnchor.ReplaceAllString(s, "[$2]($1)") + + // Images: text → ![text] + s = reImg.ReplaceAllString(s, "![$1]") + + // Bold/italic + s = reStrong.ReplaceAllString(s, "**$1**") + s = reEm.ReplaceAllString(s, "*$1*") + + // Paragraphs and breaks + s = reParagraph.ReplaceAllString(s, "\n$1\n") + s = reBreak.ReplaceAllString(s, "\n") + + // List items + s = reListItem.ReplaceAllString(s, "\n- $1") + + // Strip remaining tags + s = reTag.ReplaceAllString(s, "") + + // Clean up + s = decodeHTMLEntities(s) + s = reMultiNL.ReplaceAllString(s, "\n\n") + s = reMultiSP.ReplaceAllString(s, " ") + + return strings.TrimSpace(s) +} + +// htmlToText extracts plain text from HTML content. +func htmlToText(html string) string { + s := reScript.ReplaceAllString(html, "") + s = reStyle.ReplaceAllString(s, "") + s = reComment.ReplaceAllString(s, "") + s = reNav.ReplaceAllString(s, "") + s = reFooter.ReplaceAllString(s, "") + s = reHeader.ReplaceAllString(s, "") + + // Structural breaks + s = reParagraph.ReplaceAllString(s, "\n$1\n") + s = reBreak.ReplaceAllString(s, "\n") + s = reListItem.ReplaceAllString(s, "\n- $1") + + // Strip all tags + s = reTag.ReplaceAllString(s, "") + + s = decodeHTMLEntities(s) + s = reMultiSP.ReplaceAllString(s, " ") + s = reMultiNL.ReplaceAllString(s, "\n\n") + + // Clean lines + lines := strings.Split(s, "\n") + var clean []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + clean = append(clean, line) + } + } + return strings.Join(clean, "\n") +} + +// markdownToText strips markdown formatting for text mode. +func markdownToText(md string) string { + s := md + // Remove headers markers + s = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(s, "") + // Remove bold/italic markers + s = strings.ReplaceAll(s, "**", "") + s = strings.ReplaceAll(s, "__", "") + // Remove inline code + s = regexp.MustCompile("`[^`]+`").ReplaceAllStringFunc(s, func(m string) string { + return strings.Trim(m, "`") + }) + // Remove links: [text](url) → text + s = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(s, "$1") + // Remove images + s = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(s, "$1") + // Clean whitespace + s = reMultiNL.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} + +// decodeHTMLEntities handles common HTML entities. +func decodeHTMLEntities(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + """, `"`, + "'", "'", + "'", "'", + " ", " ", + "—", "\u2014", + "–", "\u2013", + "«", "\u00ab", + "»", "\u00bb", + "•", "\u2022", + "…", "...", + "©", "(c)", + "®", "(R)", + "™", "(TM)", + ) + return replacer.Replace(s) +} diff --git a/internal/tools/web_search.go b/internal/tools/web_search.go index c37ec987..cd7656c3 100644 --- a/internal/tools/web_search.go +++ b/internal/tools/web_search.go @@ -2,12 +2,8 @@ package tools import ( "context" - "encoding/json" "fmt" - "io" "log/slog" - "net/http" - "net/url" "regexp" "strings" "time" @@ -43,173 +39,6 @@ type searchResult struct { Description string `json:"description"` } -// --- Brave Search Provider --- - -type braveSearchProvider struct { - apiKey string - client *http.Client -} - -func newBraveSearchProvider(apiKey string) *braveSearchProvider { - return &braveSearchProvider{ - apiKey: apiKey, - client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second}, - } -} - -func (p *braveSearchProvider) Name() string { return "brave" } - -func (p *braveSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) { - q := url.Values{} - q.Set("q", params.Query) - q.Set("count", fmt.Sprintf("%d", params.Count)) - - if params.Country != "" { - q.Set("country", params.Country) - } - if params.SearchLang != "" { - q.Set("search_lang", params.SearchLang) - } - if params.UILang != "" { - q.Set("ui_lang", params.UILang) - } - if f := normalizeFreshness(params.Freshness); f != "" { - q.Set("freshness", f) - } - - reqURL := braveSearchEndpoint + "?" + q.Encode() - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("X-Subscription-Token", p.apiKey) - - resp, err := p.client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("brave API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200)) - } - - var braveResp struct { - Web struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Description string `json:"description"` - } `json:"results"` - } `json:"web"` - } - - if err := json.Unmarshal(body, &braveResp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - - results := make([]searchResult, 0, len(braveResp.Web.Results)) - for _, r := range braveResp.Web.Results { - results = append(results, searchResult{ - Title: r.Title, - URL: r.URL, - Description: r.Description, - }) - } - return results, nil -} - -// --- DuckDuckGo Search Provider --- - -type duckDuckGoSearchProvider struct { - client *http.Client -} - -func newDuckDuckGoSearchProvider() *duckDuckGoSearchProvider { - return &duckDuckGoSearchProvider{ - client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second}, - } -} - -func (p *duckDuckGoSearchProvider) Name() string { return "duckduckgo" } - -func (p *duckDuckGoSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) { - searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(params.Query)) - - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - req.Header.Set("User-Agent", webSearchUserAgent) - - resp, err := p.client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - return extractDDGResults(string(body), params.Count) -} - -var ( - ddgLinkRe = regexp.MustCompile(`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`) - ddgSnippetRe = regexp.MustCompile(`([\s\S]*?)`) - htmlTagRe = regexp.MustCompile(`<[^>]+>`) -) - -func extractDDGResults(html string, count int) ([]searchResult, error) { - linkMatches := ddgLinkRe.FindAllStringSubmatch(html, count+5) - if len(linkMatches) == 0 { - return nil, nil - } - - snippetMatches := ddgSnippetRe.FindAllStringSubmatch(html, count+5) - - var results []searchResult - for i := 0; i < len(linkMatches) && i < count; i++ { - rawURL := linkMatches[i][1] - title := strings.TrimSpace(htmlTagRe.ReplaceAllString(linkMatches[i][2], "")) - - // DDG wraps URLs with redirect — extract real URL from uddg= param - if strings.Contains(rawURL, "uddg=") { - if u, err := url.QueryUnescape(rawURL); err == nil { - if idx := strings.Index(u, "uddg="); idx != -1 { - extracted := u[idx+5:] - // uddg value may have trailing ¶ms - if ampIdx := strings.Index(extracted, "&"); ampIdx != -1 { - extracted = extracted[:ampIdx] - } - rawURL = extracted - } - } - } - - desc := "" - if i < len(snippetMatches) { - desc = strings.TrimSpace(htmlTagRe.ReplaceAllString(snippetMatches[i][1], "")) - } - - results = append(results, searchResult{ - Title: title, - URL: rawURL, - Description: desc, - }) - } - - return results, nil -} - // --- Freshness validation (matching TS) --- var ( diff --git a/internal/tools/web_search_brave.go b/internal/tools/web_search_brave.go new file mode 100644 index 00000000..3f89b12a --- /dev/null +++ b/internal/tools/web_search_brave.go @@ -0,0 +1,93 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// --- Brave Search Provider --- + +type braveSearchProvider struct { + apiKey string + client *http.Client +} + +func newBraveSearchProvider(apiKey string) *braveSearchProvider { + return &braveSearchProvider{ + apiKey: apiKey, + client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second}, + } +} + +func (p *braveSearchProvider) Name() string { return "brave" } + +func (p *braveSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) { + q := url.Values{} + q.Set("q", params.Query) + q.Set("count", fmt.Sprintf("%d", params.Count)) + + if params.Country != "" { + q.Set("country", params.Country) + } + if params.SearchLang != "" { + q.Set("search_lang", params.SearchLang) + } + if params.UILang != "" { + q.Set("ui_lang", params.UILang) + } + if f := normalizeFreshness(params.Freshness); f != "" { + q.Set("freshness", f) + } + + reqURL := braveSearchEndpoint + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Subscription-Token", p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("brave API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200)) + } + + var braveResp struct { + Web struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + } `json:"results"` + } `json:"web"` + } + + if err := json.Unmarshal(body, &braveResp); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + results := make([]searchResult, 0, len(braveResp.Web.Results)) + for _, r := range braveResp.Web.Results { + results = append(results, searchResult{ + Title: r.Title, + URL: r.URL, + Description: r.Description, + }) + } + return results, nil +} diff --git a/internal/tools/web_search_ddg.go b/internal/tools/web_search_ddg.go new file mode 100644 index 00000000..46c5b78d --- /dev/null +++ b/internal/tools/web_search_ddg.go @@ -0,0 +1,97 @@ +package tools + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +// --- DuckDuckGo Search Provider --- + +type duckDuckGoSearchProvider struct { + client *http.Client +} + +func newDuckDuckGoSearchProvider() *duckDuckGoSearchProvider { + return &duckDuckGoSearchProvider{ + client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second}, + } +} + +func (p *duckDuckGoSearchProvider) Name() string { return "duckduckgo" } + +func (p *duckDuckGoSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) { + searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(params.Query)) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("User-Agent", webSearchUserAgent) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + return extractDDGResults(string(body), params.Count) +} + +var ( + ddgLinkRe = regexp.MustCompile(`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`) + ddgSnippetRe = regexp.MustCompile(`([\s\S]*?)`) + htmlTagRe = regexp.MustCompile(`<[^>]+>`) +) + +func extractDDGResults(html string, count int) ([]searchResult, error) { + linkMatches := ddgLinkRe.FindAllStringSubmatch(html, count+5) + if len(linkMatches) == 0 { + return nil, nil + } + + snippetMatches := ddgSnippetRe.FindAllStringSubmatch(html, count+5) + + var results []searchResult + for i := 0; i < len(linkMatches) && i < count; i++ { + rawURL := linkMatches[i][1] + title := strings.TrimSpace(htmlTagRe.ReplaceAllString(linkMatches[i][2], "")) + + // DDG wraps URLs with redirect — extract real URL from uddg= param + if strings.Contains(rawURL, "uddg=") { + if u, err := url.QueryUnescape(rawURL); err == nil { + if idx := strings.Index(u, "uddg="); idx != -1 { + extracted := u[idx+5:] + // uddg value may have trailing ¶ms + if ampIdx := strings.Index(extracted, "&"); ampIdx != -1 { + extracted = extracted[:ampIdx] + } + rawURL = extracted + } + } + } + + desc := "" + if i < len(snippetMatches) { + desc = strings.TrimSpace(htmlTagRe.ReplaceAllString(snippetMatches[i][1], "")) + } + + results = append(results, searchResult{ + Title: title, + URL: rawURL, + Description: desc, + }) + } + + return results, nil +}