refactor: split large Go files (>350 lines) into smaller same-package files

Pure file reorganization — no logic changes, no renames, no refactoring.
Functions moved to new files in the same package for maintainability.

Split 13 files across 6 packages into 25 new files:
- store/pg: teams.go → teams_tasks/delegation/messaging.go; mcp_servers.go → mcp_servers_access.go
- tools: delegate.go → delegate_state/policy/events.go; subagent.go → subagent_exec/config.go;
  web_search.go → web_search_brave/ddg.go; web_fetch.go → web_fetch_convert.go;
  sessions.go → sessions_history/send.go
- providers: anthropic.go → anthropic_stream/request.go
- mcp: manager.go → manager_connect/tools/util.go
- channels/feishu: bot.go → bot_parse/policy.go; larkclient.go → larkclient_messaging.go
- cmd: gateway_consumer.go → gateway_cron.go; agent_chat.go → agent_chat_client/standalone.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-02-28 17:27:28 +07:00
1 parent 0655849d3d
commit 50a90aa8c6
38 files changed
+3817 -3607

No files matched your search

-435
View File
@@ -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
}
+222
View File
@@ -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)
}
}
}
}
+229
View File
@@ -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
}
-60
View File
@@ -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.
+73
View File
@@ -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
}
}
-322
View File
@@ -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
}
+197
View File
@@ -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
}
+137
View File
@@ -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)
}
}
-177
View File
@@ -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
}
@@ -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
}
-348
View File
@@ -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
}
+210
View File
@@ -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)
}
}
+97
View File
@@ -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
}
+58
View File
@@ -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
}
-388
View File
@@ -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 {
+216
View File
@@ -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
}
}
+184
View File
@@ -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
}
-263
View File
@@ -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()
}
+275
View File
@@ -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()
}
-396
View File
@@ -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()
}
+161
View File
@@ -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
}
+71
View File
@@ -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()
}
+198
View File
@@ -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()
}
-327
View File
@@ -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)
}
+38
View File
@@ -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)
}
+156
View File
@@ -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
}
+160
View File
@@ -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)
}
}
-226
View File
@@ -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()
}
+129
View File
@@ -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))
}
+116
View File
@@ -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()
}
-311
View File
@@ -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
}
+92
View File
@@ -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
}
+235
View File
@@ -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
}
-183
View File
@@ -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)<script[\s\S]*?</script>`)
reStyle = regexp.MustCompile(`(?is)<style[\s\S]*?</style>`)
reComment = regexp.MustCompile(`<!--[\s\S]*?-->`)
reNav = regexp.MustCompile(`(?is)<nav[\s\S]*?</nav>`)
reFooter = regexp.MustCompile(`(?is)<footer[\s\S]*?</footer>`)
reHeader = regexp.MustCompile(`(?is)<header[\s\S]*?</header>`)
reTag = regexp.MustCompile(`<[^>]+>`)
reMultiNL = regexp.MustCompile(`\n{3,}`)
reMultiSP = regexp.MustCompile(`[ \t]{2,}`)
reH1 = regexp.MustCompile(`(?i)<h1[^>]*>([\s\S]*?)</h1>`)
reH2 = regexp.MustCompile(`(?i)<h2[^>]*>([\s\S]*?)</h2>`)
reH3 = regexp.MustCompile(`(?i)<h3[^>]*>([\s\S]*?)</h3>`)
reH4 = regexp.MustCompile(`(?i)<h4[^>]*>([\s\S]*?)</h4>`)
reH5 = regexp.MustCompile(`(?i)<h5[^>]*>([\s\S]*?)</h5>`)
reH6 = regexp.MustCompile(`(?i)<h6[^>]*>([\s\S]*?)</h6>`)
reParagraph = regexp.MustCompile(`(?i)<p[^>]*>([\s\S]*?)</p>`)
reBreak = regexp.MustCompile(`(?i)<br\s*/?>`)
reListItem = regexp.MustCompile(`(?i)<li[^>]*>([\s\S]*?)</li>`)
reAnchor = regexp.MustCompile(`(?i)<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)</a>`)
rePre = regexp.MustCompile(`(?is)<pre[^>]*>([\s\S]*?)</pre>`)
reCode = regexp.MustCompile(`(?i)<code[^>]*>([\s\S]*?)</code>`)
reStrong = regexp.MustCompile(`(?i)<(?:strong|b)[^>]*>([\s\S]*?)</(?:strong|b)>`)
reEm = regexp.MustCompile(`(?i)<(?:em|i)[^>]*>([\s\S]*?)</(?:em|i)>`)
reBlockq = regexp.MustCompile(`(?is)<blockquote[^>]*>([\s\S]*?)</blockquote>`)
reImg = regexp.MustCompile(`(?i)<img[^>]*alt="([^"]*)"[^>]*/?>`)
)
// htmlToMarkdown converts HTML to a markdown-like format.
// Not a full Readability implementation but covers common patterns.
func htmlToMarkdown(html string) string {
// Remove non-content elements
s := reScript.ReplaceAllString(html, "")
s = reStyle.ReplaceAllString(s, "")
s = reComment.ReplaceAllString(s, "")
s = reNav.ReplaceAllString(s, "")
s = reFooter.ReplaceAllString(s, "")
// Convert headings
s = reH1.ReplaceAllString(s, "\n# $1\n")
s = reH2.ReplaceAllString(s, "\n## $1\n")
s = reH3.ReplaceAllString(s, "\n### $1\n")
s = reH4.ReplaceAllString(s, "\n#### $1\n")
s = reH5.ReplaceAllString(s, "\n##### $1\n")
s = reH6.ReplaceAllString(s, "\n###### $1\n")
// Pre/code blocks (before stripping other tags)
s = rePre.ReplaceAllString(s, "\n```\n$1\n```\n")
s = reCode.ReplaceAllString(s, "`$1`")
// Blockquotes
s = reBlockq.ReplaceAllStringFunc(s, func(match string) string {
inner := reBlockq.FindStringSubmatch(match)
if len(inner) < 2 {
return match
}
lines := strings.Split(strings.TrimSpace(inner[1]), "\n")
var quoted []string
for _, l := range lines {
quoted = append(quoted, "> "+strings.TrimSpace(l))
}
return "\n" + strings.Join(quoted, "\n") + "\n"
})
// Links: <a href="url">text</a> → [text](url)
s = reAnchor.ReplaceAllString(s, "[$2]($1)")
// Images: <img alt="text" ... /> → ![text]
s = reImg.ReplaceAllString(s, "![$1]")
// Bold/italic
s = reStrong.ReplaceAllString(s, "**$1**")
s = reEm.ReplaceAllString(s, "*$1*")
// Paragraphs and breaks
s = reParagraph.ReplaceAllString(s, "\n$1\n")
s = reBreak.ReplaceAllString(s, "\n")
// List items
s = reListItem.ReplaceAllString(s, "\n- $1")
// Strip remaining tags
s = reTag.ReplaceAllString(s, "")
// Clean up
s = decodeHTMLEntities(s)
s = reMultiNL.ReplaceAllString(s, "\n\n")
s = reMultiSP.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
// htmlToText extracts plain text from HTML content.
func htmlToText(html string) string {
s := reScript.ReplaceAllString(html, "")
s = reStyle.ReplaceAllString(s, "")
s = reComment.ReplaceAllString(s, "")
s = reNav.ReplaceAllString(s, "")
s = reFooter.ReplaceAllString(s, "")
s = reHeader.ReplaceAllString(s, "")
// Structural breaks
s = reParagraph.ReplaceAllString(s, "\n$1\n")
s = reBreak.ReplaceAllString(s, "\n")
s = reListItem.ReplaceAllString(s, "\n- $1")
// Strip all tags
s = reTag.ReplaceAllString(s, "")
s = decodeHTMLEntities(s)
s = reMultiSP.ReplaceAllString(s, " ")
s = reMultiNL.ReplaceAllString(s, "\n\n")
// Clean lines
lines := strings.Split(s, "\n")
var clean []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
clean = append(clean, line)
}
}
return strings.Join(clean, "\n")
}
// markdownToText strips markdown formatting for text mode.
func markdownToText(md string) string {
s := md
// Remove headers markers
s = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(s, "")
// Remove bold/italic markers
s = strings.ReplaceAll(s, "**", "")
s = strings.ReplaceAll(s, "__", "")
// Remove inline code
s = regexp.MustCompile("`[^`]+`").ReplaceAllStringFunc(s, func(m string) string {
return strings.Trim(m, "`")
})
// Remove links: [text](url) → text
s = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(s, "$1")
// Remove images
s = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(s, "$1")
// Clean whitespace
s = reMultiNL.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
// decodeHTMLEntities handles common HTML entities.
func decodeHTMLEntities(s string) string {
replacer := strings.NewReplacer(
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&#39;", "'",
"&apos;", "'",
"&nbsp;", " ",
"&mdash;", "—",
"&ndash;", "–",
"&laquo;", "«",
"&raquo;", "»",
"&bull;", "•",
"&hellip;", "...",
"&copy;", "(c)",
"&reg;", "(R)",
"&trade;", "(TM)",
)
return replacer.Replace(s)
}
+188
View File
@@ -0,0 +1,188 @@
package tools
import (
"encoding/json"
"regexp"
"strings"
)
// extractJSON pretty-prints JSON content.
func extractJSON(body []byte) (string, string) {
var data interface{}
if err := json.Unmarshal(body, &data); err == nil {
formatted, _ := json.MarshalIndent(data, "", " ")
return string(formatted), "json"
}
return string(body), "raw"
}
// --- HTML extraction utilities ---
var (
reScript = regexp.MustCompile(`(?is)<script[\s\S]*?</script>`)
reStyle = regexp.MustCompile(`(?is)<style[\s\S]*?</style>`)
reComment = regexp.MustCompile(`<!--[\s\S]*?-->`)
reNav = regexp.MustCompile(`(?is)<nav[\s\S]*?</nav>`)
reFooter = regexp.MustCompile(`(?is)<footer[\s\S]*?</footer>`)
reHeader = regexp.MustCompile(`(?is)<header[\s\S]*?</header>`)
reTag = regexp.MustCompile(`<[^>]+>`)
reMultiNL = regexp.MustCompile(`\n{3,}`)
reMultiSP = regexp.MustCompile(`[ \t]{2,}`)
reH1 = regexp.MustCompile(`(?i)<h1[^>]*>([\s\S]*?)</h1>`)
reH2 = regexp.MustCompile(`(?i)<h2[^>]*>([\s\S]*?)</h2>`)
reH3 = regexp.MustCompile(`(?i)<h3[^>]*>([\s\S]*?)</h3>`)
reH4 = regexp.MustCompile(`(?i)<h4[^>]*>([\s\S]*?)</h4>`)
reH5 = regexp.MustCompile(`(?i)<h5[^>]*>([\s\S]*?)</h5>`)
reH6 = regexp.MustCompile(`(?i)<h6[^>]*>([\s\S]*?)</h6>`)
reParagraph = regexp.MustCompile(`(?i)<p[^>]*>([\s\S]*?)</p>`)
reBreak = regexp.MustCompile(`(?i)<br\s*/?>`)
reListItem = regexp.MustCompile(`(?i)<li[^>]*>([\s\S]*?)</li>`)
reAnchor = regexp.MustCompile(`(?i)<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)</a>`)
rePre = regexp.MustCompile(`(?is)<pre[^>]*>([\s\S]*?)</pre>`)
reCode = regexp.MustCompile(`(?i)<code[^>]*>([\s\S]*?)</code>`)
reStrong = regexp.MustCompile(`(?i)<(?:strong|b)[^>]*>([\s\S]*?)</(?:strong|b)>`)
reEm = regexp.MustCompile(`(?i)<(?:em|i)[^>]*>([\s\S]*?)</(?:em|i)>`)
reBlockq = regexp.MustCompile(`(?is)<blockquote[^>]*>([\s\S]*?)</blockquote>`)
reImg = regexp.MustCompile(`(?i)<img[^>]*alt="([^"]*)"[^>]*/?>`)
)
// htmlToMarkdown converts HTML to a markdown-like format.
// Not a full Readability implementation but covers common patterns.
func htmlToMarkdown(html string) string {
// Remove non-content elements
s := reScript.ReplaceAllString(html, "")
s = reStyle.ReplaceAllString(s, "")
s = reComment.ReplaceAllString(s, "")
s = reNav.ReplaceAllString(s, "")
s = reFooter.ReplaceAllString(s, "")
// Convert headings
s = reH1.ReplaceAllString(s, "\n# $1\n")
s = reH2.ReplaceAllString(s, "\n## $1\n")
s = reH3.ReplaceAllString(s, "\n### $1\n")
s = reH4.ReplaceAllString(s, "\n#### $1\n")
s = reH5.ReplaceAllString(s, "\n##### $1\n")
s = reH6.ReplaceAllString(s, "\n###### $1\n")
// Pre/code blocks (before stripping other tags)
s = rePre.ReplaceAllString(s, "\n```\n$1\n```\n")
s = reCode.ReplaceAllString(s, "`$1`")
// Blockquotes
s = reBlockq.ReplaceAllStringFunc(s, func(match string) string {
inner := reBlockq.FindStringSubmatch(match)
if len(inner) < 2 {
return match
}
lines := strings.Split(strings.TrimSpace(inner[1]), "\n")
var quoted []string
for _, l := range lines {
quoted = append(quoted, "> "+strings.TrimSpace(l))
}
return "\n" + strings.Join(quoted, "\n") + "\n"
})
// Links: <a href="url">text</a> → [text](url)
s = reAnchor.ReplaceAllString(s, "[$2]($1)")
// Images: <img alt="text" ... /> → ![text]
s = reImg.ReplaceAllString(s, "![$1]")
// Bold/italic
s = reStrong.ReplaceAllString(s, "**$1**")
s = reEm.ReplaceAllString(s, "*$1*")
// Paragraphs and breaks
s = reParagraph.ReplaceAllString(s, "\n$1\n")
s = reBreak.ReplaceAllString(s, "\n")
// List items
s = reListItem.ReplaceAllString(s, "\n- $1")
// Strip remaining tags
s = reTag.ReplaceAllString(s, "")
// Clean up
s = decodeHTMLEntities(s)
s = reMultiNL.ReplaceAllString(s, "\n\n")
s = reMultiSP.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
// htmlToText extracts plain text from HTML content.
func htmlToText(html string) string {
s := reScript.ReplaceAllString(html, "")
s = reStyle.ReplaceAllString(s, "")
s = reComment.ReplaceAllString(s, "")
s = reNav.ReplaceAllString(s, "")
s = reFooter.ReplaceAllString(s, "")
s = reHeader.ReplaceAllString(s, "")
// Structural breaks
s = reParagraph.ReplaceAllString(s, "\n$1\n")
s = reBreak.ReplaceAllString(s, "\n")
s = reListItem.ReplaceAllString(s, "\n- $1")
// Strip all tags
s = reTag.ReplaceAllString(s, "")
s = decodeHTMLEntities(s)
s = reMultiSP.ReplaceAllString(s, " ")
s = reMultiNL.ReplaceAllString(s, "\n\n")
// Clean lines
lines := strings.Split(s, "\n")
var clean []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
clean = append(clean, line)
}
}
return strings.Join(clean, "\n")
}
// markdownToText strips markdown formatting for text mode.
func markdownToText(md string) string {
s := md
// Remove headers markers
s = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(s, "")
// Remove bold/italic markers
s = strings.ReplaceAll(s, "**", "")
s = strings.ReplaceAll(s, "__", "")
// Remove inline code
s = regexp.MustCompile("`[^`]+`").ReplaceAllStringFunc(s, func(m string) string {
return strings.Trim(m, "`")
})
// Remove links: [text](url) → text
s = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(s, "$1")
// Remove images
s = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(s, "$1")
// Clean whitespace
s = reMultiNL.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
// decodeHTMLEntities handles common HTML entities.
func decodeHTMLEntities(s string) string {
replacer := strings.NewReplacer(
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&#39;", "'",
"&apos;", "'",
"&nbsp;", " ",
"&mdash;", "\u2014",
"&ndash;", "\u2013",
"&laquo;", "\u00ab",
"&raquo;", "\u00bb",
"&bull;", "\u2022",
"&hellip;", "...",
"&copy;", "(c)",
"&reg;", "(R)",
"&trade;", "(TM)",
)
return replacer.Replace(s)
}
-171
View File
@@ -2,12 +2,8 @@ package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -43,173 +39,6 @@ type searchResult struct {
Description string `json:"description"`
}
// --- Brave Search Provider ---
type braveSearchProvider struct {
apiKey string
client *http.Client
}
func newBraveSearchProvider(apiKey string) *braveSearchProvider {
return &braveSearchProvider{
apiKey: apiKey,
client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second},
}
}
func (p *braveSearchProvider) Name() string { return "brave" }
func (p *braveSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) {
q := url.Values{}
q.Set("q", params.Query)
q.Set("count", fmt.Sprintf("%d", params.Count))
if params.Country != "" {
q.Set("country", params.Country)
}
if params.SearchLang != "" {
q.Set("search_lang", params.SearchLang)
}
if params.UILang != "" {
q.Set("ui_lang", params.UILang)
}
if f := normalizeFreshness(params.Freshness); f != "" {
q.Set("freshness", f)
}
reqURL := braveSearchEndpoint + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brave API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
var braveResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &braveResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
results := make([]searchResult, 0, len(braveResp.Web.Results))
for _, r := range braveResp.Web.Results {
results = append(results, searchResult{
Title: r.Title,
URL: r.URL,
Description: r.Description,
})
}
return results, nil
}
// --- DuckDuckGo Search Provider ---
type duckDuckGoSearchProvider struct {
client *http.Client
}
func newDuckDuckGoSearchProvider() *duckDuckGoSearchProvider {
return &duckDuckGoSearchProvider{
client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second},
}
}
func (p *duckDuckGoSearchProvider) Name() string { return "duckduckgo" }
func (p *duckDuckGoSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(params.Query))
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", webSearchUserAgent)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return extractDDGResults(string(body), params.Count)
}
var (
ddgLinkRe = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
ddgSnippetRe = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
)
func extractDDGResults(html string, count int) ([]searchResult, error) {
linkMatches := ddgLinkRe.FindAllStringSubmatch(html, count+5)
if len(linkMatches) == 0 {
return nil, nil
}
snippetMatches := ddgSnippetRe.FindAllStringSubmatch(html, count+5)
var results []searchResult
for i := 0; i < len(linkMatches) && i < count; i++ {
rawURL := linkMatches[i][1]
title := strings.TrimSpace(htmlTagRe.ReplaceAllString(linkMatches[i][2], ""))
// DDG wraps URLs with redirect — extract real URL from uddg= param
if strings.Contains(rawURL, "uddg=") {
if u, err := url.QueryUnescape(rawURL); err == nil {
if idx := strings.Index(u, "uddg="); idx != -1 {
extracted := u[idx+5:]
// uddg value may have trailing &params
if ampIdx := strings.Index(extracted, "&"); ampIdx != -1 {
extracted = extracted[:ampIdx]
}
rawURL = extracted
}
}
}
desc := ""
if i < len(snippetMatches) {
desc = strings.TrimSpace(htmlTagRe.ReplaceAllString(snippetMatches[i][1], ""))
}
results = append(results, searchResult{
Title: title,
URL: rawURL,
Description: desc,
})
}
return results, nil
}
// --- Freshness validation (matching TS) ---
var (
+93
View File
@@ -0,0 +1,93 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// --- Brave Search Provider ---
type braveSearchProvider struct {
apiKey string
client *http.Client
}
func newBraveSearchProvider(apiKey string) *braveSearchProvider {
return &braveSearchProvider{
apiKey: apiKey,
client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second},
}
}
func (p *braveSearchProvider) Name() string { return "brave" }
func (p *braveSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) {
q := url.Values{}
q.Set("q", params.Query)
q.Set("count", fmt.Sprintf("%d", params.Count))
if params.Country != "" {
q.Set("country", params.Country)
}
if params.SearchLang != "" {
q.Set("search_lang", params.SearchLang)
}
if params.UILang != "" {
q.Set("ui_lang", params.UILang)
}
if f := normalizeFreshness(params.Freshness); f != "" {
q.Set("freshness", f)
}
reqURL := braveSearchEndpoint + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brave API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
var braveResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &braveResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
results := make([]searchResult, 0, len(braveResp.Web.Results))
for _, r := range braveResp.Web.Results {
results = append(results, searchResult{
Title: r.Title,
URL: r.URL,
Description: r.Description,
})
}
return results, nil
}
+97
View File
@@ -0,0 +1,97 @@
package tools
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// --- DuckDuckGo Search Provider ---
type duckDuckGoSearchProvider struct {
client *http.Client
}
func newDuckDuckGoSearchProvider() *duckDuckGoSearchProvider {
return &duckDuckGoSearchProvider{
client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second},
}
}
func (p *duckDuckGoSearchProvider) Name() string { return "duckduckgo" }
func (p *duckDuckGoSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(params.Query))
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", webSearchUserAgent)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return extractDDGResults(string(body), params.Count)
}
var (
ddgLinkRe = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
ddgSnippetRe = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
)
func extractDDGResults(html string, count int) ([]searchResult, error) {
linkMatches := ddgLinkRe.FindAllStringSubmatch(html, count+5)
if len(linkMatches) == 0 {
return nil, nil
}
snippetMatches := ddgSnippetRe.FindAllStringSubmatch(html, count+5)
var results []searchResult
for i := 0; i < len(linkMatches) && i < count; i++ {
rawURL := linkMatches[i][1]
title := strings.TrimSpace(htmlTagRe.ReplaceAllString(linkMatches[i][2], ""))
// DDG wraps URLs with redirect — extract real URL from uddg= param
if strings.Contains(rawURL, "uddg=") {
if u, err := url.QueryUnescape(rawURL); err == nil {
if idx := strings.Index(u, "uddg="); idx != -1 {
extracted := u[idx+5:]
// uddg value may have trailing &params
if ampIdx := strings.Index(extracted, "&"); ampIdx != -1 {
extracted = extracted[:ampIdx]
}
rawURL = extracted
}
}
}
desc := ""
if i < len(snippetMatches) {
desc = strings.TrimSpace(htmlTagRe.ReplaceAllString(snippetMatches[i][1], ""))
}
results = append(results, searchResult{
Title: title,
URL: rawURL,
Description: desc,
})
}
return results, nil
}