mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
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:
1 parent
0655849d3d
commit
50a90aa8c6
38 files changed
+3817
-3607
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
""", `"`,
|
||||
"'", "'",
|
||||
"'", "'",
|
||||
" ", " ",
|
||||
"—", "—",
|
||||
"–", "–",
|
||||
"«", "«",
|
||||
"»", "»",
|
||||
"•", "•",
|
||||
"…", "...",
|
||||
"©", "(c)",
|
||||
"®", "(R)",
|
||||
"™", "(TM)",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
""", `"`,
|
||||
"'", "'",
|
||||
"'", "'",
|
||||
" ", " ",
|
||||
"—", "\u2014",
|
||||
"–", "\u2013",
|
||||
"«", "\u00ab",
|
||||
"»", "\u00bb",
|
||||
"•", "\u2022",
|
||||
"…", "...",
|
||||
"©", "(c)",
|
||||
"®", "(R)",
|
||||
"™", "(TM)",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
@@ -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 ¶ms
|
||||
if ampIdx := strings.Index(extracted, "&"); ampIdx != -1 {
|
||||
extracted = extracted[:ampIdx]
|
||||
}
|
||||
rawURL = extracted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
desc := ""
|
||||
if i < len(snippetMatches) {
|
||||
desc = strings.TrimSpace(htmlTagRe.ReplaceAllString(snippetMatches[i][1], ""))
|
||||
}
|
||||
|
||||
results = append(results, searchResult{
|
||||
Title: title,
|
||||
URL: rawURL,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// --- Freshness validation (matching TS) ---
|
||||
|
||||
var (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ¶ms
|
||||
if ampIdx := strings.Index(extracted, "&"); ampIdx != -1 {
|
||||
extracted = extracted[:ampIdx]
|
||||
}
|
||||
rawURL = extracted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
desc := ""
|
||||
if i < len(snippetMatches) {
|
||||
desc = strings.TrimSpace(htmlTagRe.ReplaceAllString(snippetMatches[i][1], ""))
|
||||
}
|
||||
|
||||
results = append(results, searchResult{
|
||||
Title: title,
|
||||
URL: rawURL,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
Reference in new issue
Block a user