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)