From 91c168db2042998fa90ec993febf0cc2d0b3f75c Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 15 Feb 2026 17:26:36 +0800 Subject: [PATCH 01/82] feat(mcp): add Model Context Protocol integration Implement comprehensive MCP support with stdio/HTTP/SSE transports, environment variable configuration (env and envFile), custom headers, tool registration, and automatic resource cleanup. Includes full test coverage and VSCode-compatible configuration. - Added pkg/mcp/manager.go for server lifecycle management - Added pkg/tools/mcp_tool.go for tool wrapping - Integrated into agent loop with cleanup - Support for envFile loading (.env format) - Headers injection for HTTP/SSE authentication - Example configs for filesystem, github, brave-search, postgres --- config/config.example.json | 62 ++++- go.mod | 6 +- go.sum | 10 + pkg/agent/loop.go | 46 +++- pkg/config/config.go | 61 +++++ pkg/mcp/manager.go | 432 +++++++++++++++++++++++++++++++++++ pkg/mcp/manager_test.go | 182 +++++++++++++++ pkg/tools/mcp_tool.go | 119 ++++++++++ pkg/tools/mcp_tool_test.go | 456 +++++++++++++++++++++++++++++++++++++ 9 files changed, 1366 insertions(+), 8 deletions(-) create mode 100644 pkg/mcp/manager.go create mode 100644 pkg/mcp/manager_test.go create mode 100644 pkg/tools/mcp_tool.go create mode 100644 pkg/tools/mcp_tool_test.go diff --git a/config/config.example.json b/config/config.example.json index aa75c8338..75478af4d 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -14,7 +14,9 @@ "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "proxy": "", - "allow_from": ["YOUR_USER_ID"] + "allow_from": [ + "YOUR_USER_ID" + ] }, "discord": { "enabled": false, @@ -115,6 +117,62 @@ "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "mcp": { + "enabled": false, + "servers": { + "filesystem": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ], + "env": {} + }, + "github": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + }, + "envFile": ".env" + }, + "brave-search": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-brave-search" + ], + "env": { + "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY" + } + }, + "postgres": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "remote-http-example": { + "enabled": false, + "url": "https://mcp-server.example.com/stream", + "type": "sse", + "headers": { + "Authorization": "Bearer YOUR_TOKEN", + "X-Custom-Header": "custom-value" + } + } + } } }, "heartbeat": { @@ -129,4 +187,4 @@ "host": "0.0.0.0", "port": 18790 } -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 98aecd6ab..528bc40b9 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/modelcontextprotocol/go-sdk v1.3.0 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 @@ -19,7 +20,7 @@ require ( golang.org/x/oauth2 v0.35.0 ) - +require github.com/yosida95/uritemplate/v3 v3.0.2 // indirect require ( github.com/andybalholm/brotli v1.2.0 // indirect @@ -28,9 +29,9 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/github/copilot-sdk/go v0.1.23 - github.com/google/jsonschema-go v0.4.2 // indirect github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -47,5 +48,4 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - ) diff --git a/go.sum b/go.sum index 6a565b93e..5469dd7dd 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -58,6 +60,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -84,6 +88,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= +github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -141,6 +147,8 @@ github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpB github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -228,6 +236,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f3dd94090..742ea5496 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -22,6 +22,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/mcp" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/state" @@ -40,6 +41,7 @@ type AgentLoop struct { state *state.Manager contextBuilder *ContextBuilder tools *tools.ToolRegistry + mcpManager *mcp.Manager // MCP server manager for resource cleanup running atomic.Bool summarizing sync.Map // Tracks which sessions are currently being summarized } @@ -58,7 +60,7 @@ type processOptions struct { // createToolRegistry creates a tool registry with common tools. // This is shared between main agent and subagents. -func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry { +func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus, mcpManager *mcp.Manager) *tools.ToolRegistry { registry := tools.NewToolRegistry() // File system tools @@ -99,6 +101,23 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg }) registry.Register(messageTool) + // Register MCP tools from all connected servers + if mcpManager != nil { + servers := mcpManager.GetServers() + for serverName, conn := range servers { + for _, tool := range conn.Tools { + mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) + registry.Register(mcpTool) + logger.DebugCF("agent", "Registered MCP tool", + map[string]interface{}{ + "server": serverName, + "tool": tool.Name, + "name": mcpTool.Name(), + }) + } + } + } + return registry } @@ -108,12 +127,22 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers restrict := cfg.Agents.Defaults.RestrictToWorkspace + // Initialize MCP Manager and load servers + mcpManager := mcp.NewManager() + ctx := context.Background() + if err := mcpManager.LoadFromConfig(ctx, cfg); err != nil { + logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", + map[string]interface{}{ + "error": err.Error(), + }) + } + // Create tool registry for main agent - toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus) + toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus, mcpManager) // Create subagent manager with its own tool registry subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus) - subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus) + subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus, mcpManager) // Subagent doesn't need spawn/subagent tools to avoid recursion subagentManager.SetTools(subagentTools) @@ -145,6 +174,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers state: stateManager, contextBuilder: contextBuilder, tools: toolsRegistry, + mcpManager: mcpManager, summarizing: sync.Map{}, } } @@ -193,6 +223,16 @@ func (al *AgentLoop) Run(ctx context.Context) error { func (al *AgentLoop) Stop() { al.running.Store(false) + + // Clean up MCP connections + if al.mcpManager != nil { + if err := al.mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]interface{}{ + "error": err.Error(), + }) + } + } } func (al *AgentLoop) RegisterTool(tool tools.Tool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index d76ec8095..237eade65 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -212,6 +212,35 @@ type WebToolsConfig struct { type ToolsConfig struct { Web WebToolsConfig `json:"web"` + MCP MCPConfig `json:"mcp"` +} + +// MCPServerConfig defines configuration for a single MCP server +type MCPServerConfig struct { + // Enabled indicates whether this MCP server is active + Enabled bool `json:"enabled"` + // Command is the executable to run (e.g., "npx", "python", "/path/to/server") + Command string `json:"command"` + // Args are the arguments to pass to the command + Args []string `json:"args,omitempty"` + // Env are environment variables to set for the server process (stdio only) + Env map[string]string `json:"env,omitempty"` + // EnvFile is the path to a file containing environment variables (stdio only) + EnvFile string `json:"envFile,omitempty"` + // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set) + Type string `json:"type,omitempty"` + // URL is used for SSE/HTTP transport + URL string `json:"url,omitempty"` + // Headers are HTTP headers to send with requests (sse/http only) + Headers map[string]string `json:"headers,omitempty"` +} + +// MCPConfig defines configuration for all MCP servers +type MCPConfig struct { + // Enabled globally enables/disables MCP integration + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MCP_ENABLED"` + // Servers is a map of server name to server configuration + Servers map[string]MCPServerConfig `json:"servers,omitempty"` } func DefaultConfig() *Config { @@ -321,6 +350,38 @@ func DefaultConfig() *Config { MaxResults: 5, }, }, + MCP: MCPConfig{ + Enabled: false, + Servers: map[string]MCPServerConfig{ + "filesystem": { + Enabled: false, + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, + Env: map[string]string{}, + }, + "github": { + Enabled: false, + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-github"}, + Env: map[string]string{ + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN", + }, + }, + "brave-search": { + Enabled: false, + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-brave-search"}, + Env: map[string]string{ + "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY", + }, + }, + "postgres": { + Enabled: false, + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname"}, + }, + }, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go new file mode 100644 index 000000000..d6ca28f76 --- /dev/null +++ b/pkg/mcp/manager.go @@ -0,0 +1,432 @@ +package mcp + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// headerTransport is an http.RoundTripper that adds custom headers to requests +type headerTransport struct { + base http.RoundTripper + headers map[string]string +} + +func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + req = req.Clone(req.Context()) + + // Add custom headers + for key, value := range t.headers { + req.Header.Set(key, value) + } + + // Use the base transport + base := t.base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(req) +} + +// loadEnvFile loads environment variables from a file in .env format +// Each line should be in the format: KEY=value +// Lines starting with # are comments +// Empty lines are ignored +func loadEnvFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open env file: %w", err) + } + defer file.Close() + + envVars := make(map[string]string) + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Parse KEY=value + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid format at line %d: %s", lineNum, line) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove surrounding quotes if present + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + + envVars[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading env file: %w", err) + } + + return envVars, nil +} + +// ServerConnection represents a connection to an MCP server +type ServerConnection struct { + Name string + Client *mcp.Client + Session *mcp.ClientSession + Tools []*mcp.Tool +} + +// Manager manages multiple MCP server connections +type Manager struct { + servers map[string]*ServerConnection + mu sync.RWMutex +} + +// NewManager creates a new MCP manager +func NewManager() *Manager { + return &Manager{ + servers: make(map[string]*ServerConnection), + } +} + +// LoadFromConfig loads MCP servers from configuration +func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error { + if !cfg.Tools.MCP.Enabled { + logger.InfoCF("mcp", "MCP integration is disabled", nil) + return nil + } + + if len(cfg.Tools.MCP.Servers) == 0 { + logger.InfoCF("mcp", "No MCP servers configured", nil) + return nil + } + + logger.InfoCF("mcp", "Initializing MCP servers", + map[string]interface{}{ + "count": len(cfg.Tools.MCP.Servers), + }) + + var wg sync.WaitGroup + errs := make(chan error, len(cfg.Tools.MCP.Servers)) + + for name, serverCfg := range cfg.Tools.MCP.Servers { + if !serverCfg.Enabled { + logger.DebugCF("mcp", "Skipping disabled server", + map[string]interface{}{ + "server": name, + }) + continue + } + + wg.Add(1) + go func(name string, serverCfg config.MCPServerConfig) { + defer wg.Done() + + if err := m.ConnectServer(ctx, name, serverCfg); err != nil { + logger.ErrorCF("mcp", "Failed to connect to MCP server", + map[string]interface{}{ + "server": name, + "error": err.Error(), + }) + errs <- fmt.Errorf("failed to connect to server %s: %w", name, err) + } + }(name, serverCfg) + } + + wg.Wait() + close(errs) + + // Collect errors + var allErrors []error + for err := range errs { + allErrors = append(allErrors, err) + } + + if len(allErrors) > 0 { + logger.WarnCF("mcp", "Some MCP servers failed to connect", + map[string]interface{}{ + "failed": len(allErrors), + "total": len(cfg.Tools.MCP.Servers), + }) + // Don't fail completely if some servers fail to connect + } + + connectedCount := len(m.GetServers()) + logger.InfoCF("mcp", "MCP server initialization complete", + map[string]interface{}{ + "connected": connectedCount, + "total": len(cfg.Tools.MCP.Servers), + }) + + return nil +} + +// ConnectServer connects to a single MCP server +func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCPServerConfig) error { + logger.InfoCF("mcp", "Connecting to MCP server", + map[string]interface{}{ + "server": name, + "command": cfg.Command, + "args": cfg.Args, + }) + + // Create client + client := mcp.NewClient(&mcp.Implementation{ + Name: "picoclaw", + Version: "1.0.0", + }, nil) + + // Create transport based on configuration + // Auto-detect transport type if not explicitly specified + var transport mcp.Transport + transportType := cfg.Type + + // Auto-detect: if URL is provided, use SSE; if command is provided, use stdio + if transportType == "" { + if cfg.URL != "" { + transportType = "sse" + } else if cfg.Command != "" { + transportType = "stdio" + } else { + return fmt.Errorf("either URL or command must be provided") + } + } + + switch transportType { + case "sse", "http": + if cfg.URL == "" { + return fmt.Errorf("URL is required for SSE/HTTP transport") + } + logger.DebugCF("mcp", "Using SSE/HTTP transport", + map[string]interface{}{ + "server": name, + "url": cfg.URL, + }) + + sseTransport := &mcp.StreamableClientTransport{ + Endpoint: cfg.URL, + } + + // Add custom headers if provided + if len(cfg.Headers) > 0 { + // Create a custom HTTP client with header-injecting transport + sseTransport.HTTPClient = &http.Client{ + Transport: &headerTransport{ + base: http.DefaultTransport, + headers: cfg.Headers, + }, + } + logger.DebugCF("mcp", "Added custom HTTP headers", + map[string]interface{}{ + "server": name, + "header_count": len(cfg.Headers), + }) + } + + transport = sseTransport + case "stdio": + if cfg.Command == "" { + return fmt.Errorf("command is required for stdio transport") + } + logger.DebugCF("mcp", "Using stdio transport", + map[string]interface{}{ + "server": name, + "command": cfg.Command, + }) + // Create command with context + cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) + + // Set environment variables + env := cmd.Environ() + + // Load environment variables from file if specified + if cfg.EnvFile != "" { + envVars, err := loadEnvFile(cfg.EnvFile) + if err != nil { + return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) + } + for k, v := range envVars { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + logger.DebugCF("mcp", "Loaded environment variables from file", + map[string]interface{}{ + "server": name, + "envFile": cfg.EnvFile, + "var_count": len(envVars), + }) + } + + // Environment variables from config override those from file + if len(cfg.Env) > 0 { + for k, v := range cfg.Env { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + } + + // Set environment if we added any variables + if len(env) > len(cmd.Environ()) { + cmd.Env = env + } + + transport = &mcp.CommandTransport{Command: cmd} + default: + return fmt.Errorf("unsupported transport type: %s (supported: stdio, sse, http)", transportType) + } + + // Connect to server + session, err := client.Connect(ctx, transport, nil) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + + // Get server info + initResult := session.InitializeResult() + logger.InfoCF("mcp", "Connected to MCP server", + map[string]interface{}{ + "server": name, + "serverName": initResult.ServerInfo.Name, + "serverVersion": initResult.ServerInfo.Version, + "protocol": initResult.ProtocolVersion, + }) + + // List available tools if supported + var tools []*mcp.Tool + if initResult.Capabilities.Tools != nil { + for tool, err := range session.Tools(ctx, nil) { + if err != nil { + logger.WarnCF("mcp", "Error listing tool", + map[string]interface{}{ + "server": name, + "error": err.Error(), + }) + continue + } + tools = append(tools, tool) + } + + logger.InfoCF("mcp", "Listed tools from MCP server", + map[string]interface{}{ + "server": name, + "toolCount": len(tools), + }) + } + + // Store connection + m.mu.Lock() + m.servers[name] = &ServerConnection{ + Name: name, + Client: client, + Session: session, + Tools: tools, + } + m.mu.Unlock() + + return nil +} + +// GetServers returns all connected servers +func (m *Manager) GetServers() map[string]*ServerConnection { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string]*ServerConnection, len(m.servers)) + for k, v := range m.servers { + result[k] = v + } + return result +} + +// GetServer returns a specific server connection +func (m *Manager) GetServer(name string) (*ServerConnection, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + conn, ok := m.servers[name] + return conn, ok +} + +// CallTool calls a tool on a specific server +func (m *Manager) CallTool(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + conn, ok := m.GetServer(serverName) + if !ok { + return nil, fmt.Errorf("server %s not found", serverName) + } + + params := &mcp.CallToolParams{ + Name: toolName, + Arguments: arguments, + } + + result, err := conn.Session.CallTool(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to call tool: %w", err) + } + + return result, nil +} + +// Close closes all server connections +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + logger.InfoCF("mcp", "Closing all MCP server connections", + map[string]interface{}{ + "count": len(m.servers), + }) + + var errs []error + for name, conn := range m.servers { + if err := conn.Session.Close(); err != nil { + logger.ErrorCF("mcp", "Failed to close server connection", + map[string]interface{}{ + "server": name, + "error": err.Error(), + }) + errs = append(errs, err) + } + } + + m.servers = make(map[string]*ServerConnection) + + if len(errs) > 0 { + return fmt.Errorf("failed to close %d server(s)", len(errs)) + } + + return nil +} + +// GetAllTools returns all tools from all connected servers +func (m *Manager) GetAllTools() map[string][]*mcp.Tool { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string][]*mcp.Tool) + for name, conn := range m.servers { + if len(conn.Tools) > 0 { + result[name] = conn.Tools + } + } + return result +} diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go new file mode 100644 index 000000000..e888d8764 --- /dev/null +++ b/pkg/mcp/manager_test.go @@ -0,0 +1,182 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadEnvFile(t *testing.T) { + tests := []struct { + name string + content string + expected map[string]string + expectErr bool + }{ + { + name: "basic env file", + content: `API_KEY=secret123 +DATABASE_URL=postgres://localhost/db +PORT=8080`, + expected: map[string]string{ + "API_KEY": "secret123", + "DATABASE_URL": "postgres://localhost/db", + "PORT": "8080", + }, + expectErr: false, + }, + { + name: "with comments and empty lines", + content: `# This is a comment +API_KEY=secret123 + +# Another comment +DATABASE_URL=postgres://localhost/db + +PORT=8080`, + expected: map[string]string{ + "API_KEY": "secret123", + "DATABASE_URL": "postgres://localhost/db", + "PORT": "8080", + }, + expectErr: false, + }, + { + name: "with quoted values", + content: `API_KEY="secret with spaces" +NAME='single quoted' +PLAIN=no-quotes`, + expected: map[string]string{ + "API_KEY": "secret with spaces", + "NAME": "single quoted", + "PLAIN": "no-quotes", + }, + expectErr: false, + }, + { + name: "with spaces around equals", + content: `API_KEY = secret123 +DATABASE_URL= postgres://localhost/db +PORT =8080`, + expected: map[string]string{ + "API_KEY": "secret123", + "DATABASE_URL": "postgres://localhost/db", + "PORT": "8080", + }, + expectErr: false, + }, + { + name: "invalid format - no equals", + content: `INVALID_LINE`, + expectErr: true, + }, + { + name: "empty file", + content: ``, + expected: map[string]string{}, + expectErr: false, + }, + { + name: "only comments", + content: `# Comment 1 +# Comment 2`, + expected: map[string]string{}, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + + if err := os.WriteFile(envFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + result, err := loadEnvFile(envFile) + + if tt.expectErr { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(result) != len(tt.expected) { + t.Errorf("Expected %d variables, got %d", len(tt.expected), len(result)) + } + + for key, expectedValue := range tt.expected { + if actualValue, ok := result[key]; !ok { + t.Errorf("Expected key %s not found", key) + } else if actualValue != expectedValue { + t.Errorf("For key %s: expected %q, got %q", key, expectedValue, actualValue) + } + } + }) + } +} + +func TestLoadEnvFileNotFound(t *testing.T) { + _, err := loadEnvFile("/nonexistent/file.env") + if err == nil { + t.Error("Expected error for nonexistent file") + } +} + +func TestEnvFilePriority(t *testing.T) { + // Create a temporary .env file + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + + envContent := `API_KEY=from_file +DATABASE_URL=from_file +SHARED_VAR=from_file` + + if err := os.WriteFile(envFile, []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Load envFile + envVars, err := loadEnvFile(envFile) + if err != nil { + t.Fatalf("Failed to load env file: %v", err) + } + + // Verify envFile variables + if envVars["API_KEY"] != "from_file" { + t.Errorf("Expected API_KEY=from_file, got %s", envVars["API_KEY"]) + } + + // Simulate config.Env overriding envFile + configEnv := map[string]string{ + "SHARED_VAR": "from_config", + "NEW_VAR": "from_config", + } + + // Merge: envFile first, then config overrides + merged := make(map[string]string) + for k, v := range envVars { + merged[k] = v + } + for k, v := range configEnv { + merged[k] = v + } + + // Verify priority: config.Env should override envFile + if merged["SHARED_VAR"] != "from_config" { + t.Errorf("Expected SHARED_VAR=from_config (config should override file), got %s", merged["SHARED_VAR"]) + } + if merged["API_KEY"] != "from_file" { + t.Errorf("Expected API_KEY=from_file, got %s", merged["API_KEY"]) + } + if merged["NEW_VAR"] != "from_config" { + t.Errorf("Expected NEW_VAR=from_config, got %s", merged["NEW_VAR"]) + } +} diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go new file mode 100644 index 000000000..e08a526ca --- /dev/null +++ b/pkg/tools/mcp_tool.go @@ -0,0 +1,119 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + mcpPkg "github.com/sipeed/picoclaw/pkg/mcp" +) + +// MCPManager defines the interface for MCP manager operations +// This allows for easier testing with mock implementations +type MCPManager interface { + CallTool(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) +} + +// MCPTool wraps an MCP tool to implement the Tool interface +type MCPTool struct { + manager MCPManager + serverName string + tool *mcp.Tool +} + +// NewMCPTool creates a new MCP tool wrapper +func NewMCPTool(manager *mcpPkg.Manager, serverName string, tool *mcp.Tool) *MCPTool { + return &MCPTool{ + manager: manager, + serverName: serverName, + tool: tool, + } +} + +// Name returns the tool name, prefixed with the server name +func (t *MCPTool) Name() string { + // Prefix with server name to avoid conflicts + return fmt.Sprintf("mcp_%s_%s", t.serverName, t.tool.Name) +} + +// Description returns the tool description +func (t *MCPTool) Description() string { + desc := t.tool.Description + if desc == "" { + desc = fmt.Sprintf("MCP tool from %s server", t.serverName) + } + // Add server info to description + return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc) +} + +// Parameters returns the tool parameters schema +func (t *MCPTool) Parameters() map[string]interface{} { + // The InputSchema is already a JSON Schema object + schema := t.tool.InputSchema + + // Convert to map[string]interface{} for compatibility + result := make(map[string]interface{}) + + // Use reflection to convert the schema + // The schema should already be in the correct format + if schema != nil { + // Attempt to convert directly + if schemaMap, ok := schema.(map[string]interface{}); ok { + return schemaMap + } + + // Otherwise, build it manually + result["type"] = "object" + result["properties"] = map[string]interface{}{} + result["required"] = []string{} + } else { + // Default schema when nil + result["type"] = "object" + result["properties"] = map[string]interface{}{} + result["required"] = []string{} + } + + return result +} + +// Execute executes the MCP tool +func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args) + if err != nil { + return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) + } + + // Handle error result from server + if result.IsError { + errMsg := extractContentText(result.Content) + return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)). + WithError(fmt.Errorf("MCP tool error: %s", errMsg)) + } + + // Extract text content from result + output := extractContentText(result.Content) + + return &ToolResult{ + ForLLM: output, + IsError: false, + } +} + +// extractContentText extracts text from MCP content array +func extractContentText(content []mcp.Content) string { + var parts []string + for _, c := range content { + switch v := c.(type) { + case *mcp.TextContent: + parts = append(parts, v.Text) + case *mcp.ImageContent: + // For images, just indicate that an image was returned + parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType)) + default: + // For other content types, use string representation + parts = append(parts, fmt.Sprintf("[Content: %T]", v)) + } + } + return strings.Join(parts, "\n") +} diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go new file mode 100644 index 000000000..3be5d95a0 --- /dev/null +++ b/pkg/tools/mcp_tool_test.go @@ -0,0 +1,456 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MockMCPManager is a mock implementation of MCPManager interface for testing +type MockMCPManager struct { + callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) +} + +func (m *MockMCPManager) CallTool(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + if m.callToolFunc != nil { + return m.callToolFunc(ctx, serverName, toolName, arguments) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "mock result"}, + }, + IsError: false, + }, nil +} + +// newMCPToolForTest creates an MCP tool for testing with mock manager +func newMCPToolForTest(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { + return &MCPTool{ + manager: manager, + serverName: serverName, + tool: tool, + } +} + +// TestNewMCPTool verifies MCP tool creation +func TestNewMCPTool(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{ + Name: "test_tool", + Description: "A test tool", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "input": map[string]interface{}{ + "type": "string", + "description": "Test input", + }, + }, + }, + } + + mcpTool := newMCPToolForTest(manager, "test_server", tool) + + if mcpTool == nil { + t.Fatal("NewMCPTool should not return nil") + } + // Verify tool properties we can access + if mcpTool.Name() != "mcp_test_server_test_tool" { + t.Errorf("Expected tool name with prefix, got '%s'", mcpTool.Name()) + } +} + +// TestMCPTool_Name verifies tool name with server prefix +func TestMCPTool_Name(t *testing.T) { + tests := []struct { + name string + serverName string + toolName string + expected string + }{ + { + name: "simple name", + serverName: "github", + toolName: "create_issue", + expected: "mcp_github_create_issue", + }, + { + name: "filesystem server", + serverName: "filesystem", + toolName: "read_file", + expected: "mcp_filesystem_read_file", + }, + { + name: "remote server", + serverName: "remote-api", + toolName: "fetch_data", + expected: "mcp_remote-api_fetch_data", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{Name: tt.toolName} + mcpTool := newMCPToolForTest(manager, tt.serverName, tool) + + result := mcpTool.Name() + if result != tt.expected { + t.Errorf("Expected name '%s', got '%s'", tt.expected, result) + } + }) + } +} + +// TestMCPTool_Description verifies tool description generation +func TestMCPTool_Description(t *testing.T) { + tests := []struct { + name string + serverName string + toolDescription string + expectContains []string + }{ + { + name: "with description", + serverName: "github", + toolDescription: "Create a GitHub issue", + expectContains: []string{"[MCP:github]", "Create a GitHub issue"}, + }, + { + name: "empty description", + serverName: "filesystem", + toolDescription: "", + expectContains: []string{"[MCP:filesystem]", "MCP tool from filesystem server"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{ + Name: "test_tool", + Description: tt.toolDescription, + } + mcpTool := newMCPToolForTest(manager, tt.serverName, tool) + + result := mcpTool.Description() + + for _, expected := range tt.expectContains { + if !strings.Contains(result, expected) { + t.Errorf("Description should contain '%s', got: %s", expected, result) + } + } + }) + } +} + +// TestMCPTool_Parameters verifies parameter schema conversion +func TestMCPTool_Parameters(t *testing.T) { + tests := []struct { + name string + inputSchema interface{} + expectType string + }{ + { + name: "map schema", + inputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query", + }, + }, + "required": []string{"query"}, + }, + expectType: "object", + }, + { + name: "nil schema", + inputSchema: nil, + expectType: "object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{ + Name: "test_tool", + InputSchema: tt.inputSchema, + } + mcpTool := newMCPToolForTest(manager, "test_server", tool) + + params := mcpTool.Parameters() + + if params == nil { + t.Fatal("Parameters should not be nil") + } + + if params["type"] != tt.expectType { + t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"]) + } + }) + } +} + +// TestMCPTool_Execute_Success tests successful tool execution +func TestMCPTool_Execute_Success(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + // Verify correct parameters passed + if serverName != "github" { + t.Errorf("Expected serverName 'github', got '%s'", serverName) + } + if toolName != "search_repos" { + t.Errorf("Expected toolName 'search_repos', got '%s'", toolName) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Found 3 repositories"}, + }, + IsError: false, + }, nil + }, + } + + tool := &mcp.Tool{ + Name: "search_repos", + Description: "Search GitHub repositories", + } + mcpTool := newMCPToolForTest(manager, "github", tool) + + ctx := context.Background() + args := map[string]interface{}{ + "query": "golang mcp", + } + + result := mcpTool.Execute(ctx, args) + + if result == nil { + t.Fatal("Result should not be nil") + } + if result.IsError { + t.Errorf("Expected no error, got error: %s", result.ForLLM) + } + if result.ForLLM != "Found 3 repositories" { + t.Errorf("Expected 'Found 3 repositories', got '%s'", result.ForLLM) + } +} + +// TestMCPTool_Execute_ManagerError tests execution when manager returns error +func TestMCPTool_Execute_ManagerError(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + return nil, fmt.Errorf("connection failed") + }, + } + + tool := &mcp.Tool{Name: "test_tool"} + mcpTool := newMCPToolForTest(manager, "test_server", tool) + + ctx := context.Background() + result := mcpTool.Execute(ctx, map[string]interface{}{}) + + if result == nil { + t.Fatal("Result should not be nil") + } + if !result.IsError { + t.Error("Expected IsError to be true") + } + if !strings.Contains(result.ForLLM, "MCP tool execution failed") { + t.Errorf("Error message should mention execution failure, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "connection failed") { + t.Errorf("Error message should include original error, got: %s", result.ForLLM) + } +} + +// TestMCPTool_Execute_ServerError tests execution when server returns error +func TestMCPTool_Execute_ServerError(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Invalid API key"}, + }, + IsError: true, + }, nil + }, + } + + tool := &mcp.Tool{Name: "test_tool"} + mcpTool := newMCPToolForTest(manager, "test_server", tool) + + ctx := context.Background() + result := mcpTool.Execute(ctx, map[string]interface{}{}) + + if result == nil { + t.Fatal("Result should not be nil") + } + if !result.IsError { + t.Error("Expected IsError to be true") + } + if !strings.Contains(result.ForLLM, "MCP tool returned error") { + t.Errorf("Error message should mention server error, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Invalid API key") { + t.Errorf("Error message should include server message, got: %s", result.ForLLM) + } +} + +// TestMCPTool_Execute_MultipleContent tests execution with multiple content items +func TestMCPTool_Execute_MultipleContent(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "First line"}, + &mcp.TextContent{Text: "Second line"}, + &mcp.TextContent{Text: "Third line"}, + }, + IsError: false, + }, nil + }, + } + + tool := &mcp.Tool{Name: "multi_output"} + mcpTool := newMCPToolForTest(manager, "test_server", tool) + + ctx := context.Background() + result := mcpTool.Execute(ctx, map[string]interface{}{}) + + if result.IsError { + t.Errorf("Expected no error, got: %s", result.ForLLM) + } + + expected := "First line\nSecond line\nThird line" + if result.ForLLM != expected { + t.Errorf("Expected '%s', got '%s'", expected, result.ForLLM) + } +} + +// TestExtractContentText_TextContent tests text content extraction +func TestExtractContentText_TextContent(t *testing.T) { + content := []mcp.Content{ + &mcp.TextContent{Text: "Hello World"}, + &mcp.TextContent{Text: "Second message"}, + } + + result := extractContentText(content) + expected := "Hello World\nSecond message" + + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } +} + +// TestExtractContentText_ImageContent tests image content extraction +func TestExtractContentText_ImageContent(t *testing.T) { + content := []mcp.Content{ + &mcp.ImageContent{ + Data: []byte("base64data"), + MIMEType: "image/png", + }, + } + + result := extractContentText(content) + + if !strings.Contains(result, "[Image:") { + t.Errorf("Expected image indicator, got: %s", result) + } + if !strings.Contains(result, "image/png") { + t.Errorf("Expected MIME type in output, got: %s", result) + } +} + +// TestExtractContentText_MixedContent tests mixed content types +func TestExtractContentText_MixedContent(t *testing.T) { + content := []mcp.Content{ + &mcp.TextContent{Text: "Description"}, + &mcp.ImageContent{ + Data: []byte("data"), + MIMEType: "image/jpeg", + }, + &mcp.TextContent{Text: "More text"}, + } + + result := extractContentText(content) + + if !strings.Contains(result, "Description") { + t.Errorf("Should contain text content, got: %s", result) + } + if !strings.Contains(result, "[Image:") { + t.Errorf("Should contain image indicator, got: %s", result) + } + if !strings.Contains(result, "More text") { + t.Errorf("Should contain second text, got: %s", result) + } +} + +// TestExtractContentText_EmptyContent tests empty content array +func TestExtractContentText_EmptyContent(t *testing.T) { + content := []mcp.Content{} + + result := extractContentText(content) + + if result != "" { + t.Errorf("Expected empty string for empty content, got: %s", result) + } +} + +// TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface +func TestMCPTool_InterfaceCompliance(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{Name: "test"} + mcpTool := newMCPToolForTest(manager, "test_server", tool) + + // Verify it implements Tool interface + var _ Tool = mcpTool +} + +// TestMCPTool_Parameters_MapSchema tests schema that's already a map +func TestMCPTool_Parameters_MapSchema(t *testing.T) { + manager := &MockMCPManager{} + schema := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "The name parameter", + }, + }, + "required": []string{"name"}, + } + + tool := &mcp.Tool{ + Name: "test_tool", + InputSchema: schema, + } + mcpTool := newMCPToolForTest(manager, "test_server", tool) + + params := mcpTool.Parameters() + + // Should return the schema as-is when it's already a map + if params["type"] != "object" { + t.Errorf("Expected type 'object', got '%v'", params["type"]) + } + + props, ok := params["properties"].(map[string]interface{}) + if !ok { + t.Error("Properties should be a map") + } + + nameParam, ok := props["name"].(map[string]interface{}) + if !ok { + t.Error("Name parameter should exist") + } + + if nameParam["type"] != "string" { + t.Errorf("Name type should be 'string', got '%v'", nameParam["type"]) + } +} From ce3fc4bc67ee7362ff536516d43f5af826e2f61f Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 14:48:40 +0800 Subject: [PATCH 02/82] feat(docker): add full-featured Docker image with MCP tools support Add Dockerfile.full with Debian-based runtime including git, nodejs, npm, python3, and uv for MCP servers. Add docker-compose.full.yml with npm cache optimization. Add Makefile targets for docker-build-full, docker-run-full, and docker-test. Add test script for MCP tools validation. --- Dockerfile.full | 47 +++++++++++++++++++++++++++++++++++++ Makefile | 40 +++++++++++++++++++++++++++++++ docker-compose.full.yml | 44 ++++++++++++++++++++++++++++++++++ scripts/test-docker-mcp.sh | 48 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 Dockerfile.full create mode 100644 docker-compose.full.yml create mode 100644 scripts/test-docker-mcp.sh diff --git a/Dockerfile.full b/Dockerfile.full new file mode 100644 index 000000000..5f805752a --- /dev/null +++ b/Dockerfile.full @@ -0,0 +1,47 @@ +# ============================================================ +# Stage 1: Build the picoclaw binary +# ============================================================ +FROM golang:1.26.0-alpine AS builder + +RUN apk add --no-cache git make + +WORKDIR /src + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build +COPY . . +RUN make build + +# ============================================================ +# Stage 2: Debian-based runtime with full MCP support +# ============================================================ +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + nodejs \ + npm \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g npm@latest + +# Install uv +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.cargo/bin:$PATH" + +# Copy binary +COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw + +# Create picoclaw home directory +RUN /usr/local/bin/picoclaw onboard + +ENTRYPOINT ["picoclaw"] +CMD ["gateway"] diff --git a/Makefile b/Makefile index 058fdb790..00258a074 100644 --- a/Makefile +++ b/Makefile @@ -140,6 +140,44 @@ deps: run: build @$(BUILD_DIR)/$(BINARY_NAME) $(ARGS) +## docker-build: Build Docker image (minimal Alpine-based) +docker-build: + @echo "Building minimal Docker image (Alpine-based)..." + docker-compose build + +## docker-build-full: Build Docker image with full MCP support (Debian-based) +docker-build-full: + @echo "Building full-featured Docker image (Debian-based)..." + docker-compose -f docker-compose.full.yml build + +## docker-test: Test MCP tools in Docker container +docker-test: + @echo "Testing MCP tools in Docker..." + @chmod +x scripts/test-docker-mcp.sh + @./scripts/test-docker-mcp.sh + +## docker-run: Run picoclaw gateway in Docker (Alpine-based) +docker-run: + docker-compose --profile gateway up + +## docker-run-full: Run picoclaw gateway in Docker (full-featured) +docker-run-full: + docker-compose -f docker-compose.full.yml --profile gateway up + +## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based) +docker-run-agent: + docker-compose run --rm picoclaw-agent + +## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured) +docker-run-agent-full: + docker-compose -f docker-compose.full.yml run --rm picoclaw-agent + +## docker-clean: Clean Docker images and volumes +docker-clean: + docker-compose down -v + docker-compose -f docker-compose.full.yml down -v + docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true + ## help: Show this help message help: @echo "picoclaw Makefile" @@ -155,6 +193,8 @@ help: @echo " make install # Install to ~/.local/bin" @echo " make uninstall # Remove from /usr/local/bin" @echo " make install-skills # Install skills to workspace" + @echo " make docker-build # Build minimal Docker image" + @echo " make docker-test # Test MCP tools in Docker" @echo "" @echo "Environment Variables:" @echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)" diff --git a/docker-compose.full.yml b/docker-compose.full.yml new file mode 100644 index 000000000..ff2694173 --- /dev/null +++ b/docker-compose.full.yml @@ -0,0 +1,44 @@ +services: + # ───────────────────────────────────────────── + # PicoClaw Agent (one-shot query) - Full MCP Support + # docker compose -f docker-compose.full.yml run --rm picoclaw-agent -m "Hello" + # ───────────────────────────────────────────── + picoclaw-agent: + build: + context: . + dockerfile: Dockerfile.full + container_name: picoclaw-agent-full + profiles: + - agent + volumes: + - ./config/config.json:/root/.picoclaw/config.json:ro + - picoclaw-workspace:/root/.picoclaw/workspace + - picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs + entrypoint: ["picoclaw", "agent"] + stdin_open: true + tty: true + + # ───────────────────────────────────────────── + # PicoClaw Gateway (Long-running Bot) - Full MCP Support + # docker compose -f docker-compose.full.yml --profile gateway up + # ───────────────────────────────────────────── + picoclaw-gateway: + build: + context: . + dockerfile: Dockerfile.full + container_name: picoclaw-gateway-full + restart: unless-stopped + profiles: + - gateway + volumes: + # Configuration file + - ./config/config.json:/root/.picoclaw/config.json:ro + # Persistent workspace (sessions, memory, logs) + - picoclaw-workspace:/root/.picoclaw/workspace + # NPM cache for faster MCP server installs + - picoclaw-npm-cache:/root/.npm + command: ["gateway"] + +volumes: + picoclaw-workspace: + picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh new file mode 100644 index 000000000..6ace832d2 --- /dev/null +++ b/scripts/test-docker-mcp.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Test script for MCP tools in Docker (full-featured image) + +set -e + +COMPOSE_FILE="docker-compose.full.yml" + +echo "🧪 Testing MCP tools in Docker container (full-featured image)..." +echo "" + +# Build the image +echo "📦 Building Docker image..." +docker-compose -f "$COMPOSE_FILE" build + +# Test npx +echo "✅ Testing npx..." +docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'npx --version' + +# Test npm +echo "✅ Testing npm..." +docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'npm --version' + +# Test node +echo "✅ Testing Node.js..." +docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'node --version' + +# Test git +echo "✅ Testing git..." +docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'git --version' + +# Test python +echo "✅ Testing Python..." +docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'python3 --version' + +# Test uv +echo "✅ Testing uv..." +docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'uv --version' + +# Test MCP server installation (quick) +echo "✅ Testing MCP server install with npx..." +docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'npx -y cowsay "MCP works!"' + +echo "" +echo "🎉 All MCP tools are working correctly!" +echo "" +echo "Next steps:" +echo " 1. Configure MCP servers in config/config.json" +echo " 2. Run: docker-compose -f $COMPOSE_FILE --profile gateway up" From 51ed54a41410479ba9114acce4ee4d7bb66504ed Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 15:40:07 +0800 Subject: [PATCH 03/82] refactor(docker): switch to node:24-bookworm-slim base image Replace debian:bookworm-slim with node:24-bookworm-slim to: - Use latest Node.js 24 LTS and npm - Fix npm version compatibility issues (npm@11 requires node >=20.17) - Simplify Dockerfile by removing nodejs/npm installation - Better optimization from official Node.js image Changes: - Dockerfile.full: use node:24-bookworm-slim base - Remove nodejs, npm installation steps - Remove npm upgrade step (included in base image) - Update Makefile descriptions to reflect Node.js 24 --- Dockerfile.full | 9 +++------ Makefile | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Dockerfile.full b/Dockerfile.full index 5f805752a..46a2bae90 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -16,22 +16,19 @@ COPY . . RUN make build # ============================================================ -# Stage 2: Debian-based runtime with full MCP support +# Stage 2: Node.js-based runtime with full MCP support # ============================================================ -FROM debian:bookworm-slim +FROM node:24-bookworm-slim # Install runtime dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ git \ - nodejs \ - npm \ python3 \ python3-pip \ python3-venv \ - && rm -rf /var/lib/apt/lists/* \ - && npm install -g npm@latest + && rm -rf /var/lib/apt/lists/* # Install uv RUN curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/Makefile b/Makefile index 00258a074..6122e4594 100644 --- a/Makefile +++ b/Makefile @@ -145,9 +145,9 @@ docker-build: @echo "Building minimal Docker image (Alpine-based)..." docker-compose build -## docker-build-full: Build Docker image with full MCP support (Debian-based) +## docker-build-full: Build Docker image with full MCP support (Node.js 24) docker-build-full: - @echo "Building full-featured Docker image (Debian-based)..." + @echo "Building full-featured Docker image (Node.js 24)..." docker-compose -f docker-compose.full.yml build ## docker-test: Test MCP tools in Docker container From b9c2b3555a946f93c9c1941403b68a7afe04a29d Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 15:49:14 +0800 Subject: [PATCH 04/82] fix(docker): override entrypoint in test script to avoid interactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --entrypoint sh flag to docker-compose run commands in test script to bypass picoclaw agent's interactive mode. This allows direct command execution for testing MCP tools. Changes: - Add --entrypoint sh to all docker-compose run commands - Use SERVICE variable for better maintainability - Simplify command syntax: sh -c 'cmd' → -c 'cmd' --- scripts/test-docker-mcp.sh | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh index 6ace832d2..eb8901f45 100644 --- a/scripts/test-docker-mcp.sh +++ b/scripts/test-docker-mcp.sh @@ -4,6 +4,7 @@ set -e COMPOSE_FILE="docker-compose.full.yml" +SERVICE="picoclaw-agent" echo "🧪 Testing MCP tools in Docker container (full-featured image)..." echo "" @@ -14,31 +15,31 @@ docker-compose -f "$COMPOSE_FILE" build # Test npx echo "✅ Testing npx..." -docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'npx --version' +docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx --version' # Test npm echo "✅ Testing npm..." -docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'npm --version' +docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npm --version' # Test node echo "✅ Testing Node.js..." -docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'node --version' +docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'node --version' # Test git echo "✅ Testing git..." -docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'git --version' +docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'git --version' # Test python echo "✅ Testing Python..." -docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'python3 --version' +docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'python3 --version' # Test uv echo "✅ Testing uv..." -docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'uv --version' +docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version' # Test MCP server installation (quick) echo "✅ Testing MCP server install with npx..." -docker-compose -f "$COMPOSE_FILE" run --rm picoclaw-agent sh -c 'npx -y cowsay "MCP works!"' +docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx -y cowsay "MCP works!"' echo "" echo "🎉 All MCP tools are working correctly!" From c05742330dd7193eddd00b84d97ea226e0fafe83 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 15:50:46 +0800 Subject: [PATCH 05/82] refactor(docker): migrate to docker compose v2 syntax Replace docker-compose (v1) with docker compose (v2) command syntax across all files. Docker Compose v2 is now the default in modern Docker installations and uses 'docker compose' instead of 'docker-compose'. Changes: - scripts/test-docker-mcp.sh: update all 8 docker-compose commands - Makefile: update all 8 docker-compose commands in docker-* targets - No changes to file names (docker-compose.full.yml remains as-is) Compatibility: Requires Docker with Compose v2 plugin (Docker Desktop or docker-compose-plugin package) --- Makefile | 16 ++++++++-------- scripts/test-docker-mcp.sh | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 6122e4594..bf7e99bcd 100644 --- a/Makefile +++ b/Makefile @@ -143,12 +143,12 @@ run: build ## docker-build: Build Docker image (minimal Alpine-based) docker-build: @echo "Building minimal Docker image (Alpine-based)..." - docker-compose build + docker compose build ## docker-build-full: Build Docker image with full MCP support (Node.js 24) docker-build-full: @echo "Building full-featured Docker image (Node.js 24)..." - docker-compose -f docker-compose.full.yml build + docker compose -f docker-compose.full.yml build ## docker-test: Test MCP tools in Docker container docker-test: @@ -158,24 +158,24 @@ docker-test: ## docker-run: Run picoclaw gateway in Docker (Alpine-based) docker-run: - docker-compose --profile gateway up + docker compose --profile gateway up ## docker-run-full: Run picoclaw gateway in Docker (full-featured) docker-run-full: - docker-compose -f docker-compose.full.yml --profile gateway up + docker compose -f docker-compose.full.yml --profile gateway up ## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based) docker-run-agent: - docker-compose run --rm picoclaw-agent + docker compose run --rm picoclaw-agent ## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured) docker-run-agent-full: - docker-compose -f docker-compose.full.yml run --rm picoclaw-agent + docker compose -f docker-compose.full.yml run --rm picoclaw-agent ## docker-clean: Clean Docker images and volumes docker-clean: - docker-compose down -v - docker-compose -f docker-compose.full.yml down -v + docker compose down -v + docker compose -f docker-compose.full.yml down -v docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true ## help: Show this help message diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh index eb8901f45..a0c9e232a 100644 --- a/scripts/test-docker-mcp.sh +++ b/scripts/test-docker-mcp.sh @@ -11,39 +11,39 @@ echo "" # Build the image echo "📦 Building Docker image..." -docker-compose -f "$COMPOSE_FILE" build +docker compose -f "$COMPOSE_FILE" build # Test npx echo "✅ Testing npx..." -docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx --version' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx --version' # Test npm echo "✅ Testing npm..." -docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npm --version' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npm --version' # Test node echo "✅ Testing Node.js..." -docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'node --version' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'node --version' # Test git echo "✅ Testing git..." -docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'git --version' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'git --version' # Test python echo "✅ Testing Python..." -docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'python3 --version' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'python3 --version' # Test uv echo "✅ Testing uv..." -docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version' # Test MCP server installation (quick) echo "✅ Testing MCP server install with npx..." -docker-compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx -y cowsay "MCP works!"' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx -y cowsay "MCP works!"' echo "" echo "🎉 All MCP tools are working correctly!" echo "" echo "Next steps:" echo " 1. Configure MCP servers in config/config.json" -echo " 2. Run: docker-compose -f $COMPOSE_FILE --profile gateway up" +echo " 2. Run: docker compose -f $COMPOSE_FILE --profile gateway up" From 1c9c32022ed5ca15c12b552db7336499be332016 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 15:52:59 +0800 Subject: [PATCH 06/82] fix(docker): ensure uv is accessible in system PATH Symlink uv from /root/.cargo/bin to /usr/local/bin to make it accessible without relying on ENV PATH setting. Add version check to verify successful installation during build. Changes: - Symlink uv to /usr/local/bin/uv - Add 'uv --version' validation step - Remove ENV PATH setting (no longer needed) Fixes: uv: not found error in test script --- Dockerfile.full | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile.full b/Dockerfile.full index 46a2bae90..5014e879e 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -30,9 +30,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3-venv \ && rm -rf /var/lib/apt/lists/* -# Install uv -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.cargo/bin:$PATH" +# Install uv and symlink to system path +RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ + ln -s /root/.cargo/bin/uv /usr/local/bin/uv && \ + uv --version # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw From 1764181e6f40370c6fd8e37bfd6f5ecb48fdffb9 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 15:57:30 +0800 Subject: [PATCH 07/82] fix(docker): correct uv installation path Fix uv symlink path from /root/.cargo/bin to /root/.local/bin. The uv installer puts binaries in ~/.local/bin, not ~/.cargo/bin. Changes: - Update uv symlink source: /root/.local/bin/uv - Add uvx symlink as well (installed alongside uv) Fixes: /bin/sh: 1: uv: not found error during build --- Dockerfile.full | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile.full b/Dockerfile.full index 5014e879e..aebcdafe6 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -32,8 +32,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install uv and symlink to system path RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ - ln -s /root/.cargo/bin/uv /usr/local/bin/uv && \ - uv --version + ln -s /root/.local/bin/uv /usr/local/bin/uv && \ + ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \ + uv --version # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw From fcedba1c9df3aa47b704145af864e00f074ddc33 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 15:59:34 +0800 Subject: [PATCH 08/82] fix(docker): add profiles to build commands Add --profile gateway --profile agent flags to docker build commands to ensure services are built even when using profiles in compose files. Without profiles specified, docker compose build skips all services that have a profile defined, resulting in 'No services to build' warning. Changes: - docker-build: add --profile flags - docker-build-full: add --profile flags Fixes: WARN[0000] No services to build --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index bf7e99bcd..4f7e65894 100644 --- a/Makefile +++ b/Makefile @@ -143,12 +143,12 @@ run: build ## docker-build: Build Docker image (minimal Alpine-based) docker-build: @echo "Building minimal Docker image (Alpine-based)..." - docker compose build + docker compose build --profile gateway --profile agent ## docker-build-full: Build Docker image with full MCP support (Node.js 24) docker-build-full: @echo "Building full-featured Docker image (Node.js 24)..." - docker compose -f docker-compose.full.yml build + docker compose -f docker-compose.full.yml build --profile gateway --profile agent ## docker-test: Test MCP tools in Docker container docker-test: From e91e7169587674c4f8d928e2675d68ec5642275e Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 16:02:10 +0800 Subject: [PATCH 09/82] fix(docker): use service names instead of --profile flag for build Replace --profile flags with explicit service names in build commands. The 'docker compose build' command does not support --profile flag; profiles are only used for runtime operations like 'up' and 'run'. Changes: - docker-build: specify picoclaw-agent picoclaw-gateway - docker-build-full: specify picoclaw-agent picoclaw-gateway Fixes: unknown flag: --profile error --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 4f7e65894..a97f17799 100644 --- a/Makefile +++ b/Makefile @@ -143,12 +143,12 @@ run: build ## docker-build: Build Docker image (minimal Alpine-based) docker-build: @echo "Building minimal Docker image (Alpine-based)..." - docker compose build --profile gateway --profile agent + docker compose build picoclaw-agent picoclaw-gateway ## docker-build-full: Build Docker image with full MCP support (Node.js 24) docker-build-full: @echo "Building full-featured Docker image (Node.js 24)..." - docker compose -f docker-compose.full.yml build --profile gateway --profile agent + docker compose -f docker-compose.full.yml build picoclaw-agent picoclaw-gateway ## docker-test: Test MCP tools in Docker container docker-test: From 87e0336d6271bfd97d056234a8a626ca25974405 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 16:38:21 +0800 Subject: [PATCH 10/82] chore(deps): format go.mod Add blank line for better formatting consistency --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index 833093f7c..4118e0767 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + require github.com/yosida95/uritemplate/v3 v3.0.2 // indirect require ( From 24610693e4a9e6df3fbf615eab31cb7363c38e48 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 16:40:35 +0800 Subject: [PATCH 11/82] chore(docker): add execute permission to test script Make scripts/test-docker-mcp.sh executable --- scripts/test-docker-mcp.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/test-docker-mcp.sh diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh old mode 100644 new mode 100755 From bfb9d8f644b19f774fb6c4139ff386608bbe88c8 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Mon, 16 Feb 2026 13:20:36 +0200 Subject: [PATCH 12/82] feat(telegram): Init bot commands on start --- pkg/channels/telegram.go | 77 +++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 5601d508c..a1a57a533 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -7,14 +7,13 @@ import ( "net/url" "os" "regexp" + "slices" "strings" "sync" "time" - th "github.com/mymmrac/telego/telegohandler" - "github.com/mymmrac/telego" - "github.com/mymmrac/telego/telegohandler" + th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" @@ -27,6 +26,7 @@ import ( type TelegramChannel struct { *BaseChannel bot *telego.Bot + botHandler *th.BotHandler commands TelegramCommander config *config.Config chatIDs map[string]int64 @@ -87,6 +87,10 @@ func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") + if err := c.initBotCommands(ctx); err != nil { + return fmt.Errorf("failed to initialize bot commands: %w", err) + } + updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{ Timeout: 30, }) @@ -94,18 +98,18 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to start long polling: %w", err) } - bh, err := telegohandler.NewBotHandler(c.bot, updates) + bh, err := th.NewBotHandler(c.bot, updates) if err != nil { return fmt.Errorf("failed to create bot handler: %w", err) } + c.botHandler = bh - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - c.commands.Help(ctx, message) - return nil - }, th.CommandEqual("help")) bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.commands.Start(ctx, message) }, th.CommandEqual("start")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Help(ctx, message) + }, th.CommandEqual("help")) bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.commands.Show(ctx, message) @@ -124,11 +128,12 @@ func (c *TelegramChannel) Start(ctx context.Context) error { "username": c.bot.Username(), }) - go bh.Start() - go func() { - <-ctx.Done() - bh.Stop() + if err = bh.Start(); err != nil { + logger.ErrorCF("telegram", "Bot handler failed", map[string]interface{}{ + "error": err.Error(), + }) + } }() return nil @@ -136,6 +141,54 @@ func (c *TelegramChannel) Start(ctx context.Context) error { func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.setRunning(false) + if c.botHandler != nil { + _ = c.botHandler.StopWithContext(ctx) + } + return nil +} + +func (c *TelegramChannel) initBotCommands(ctx context.Context) error { + currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{ + Scope: tu.ScopeAllPrivateChats(), + }) + if err != nil { + return fmt.Errorf("get commands: %w", err) + } + + commands := []telego.BotCommand{ + { + Command: "start", + Description: "Start the bot", + }, + { + Command: "help", + Description: "Show a help message", + }, + { + Command: "show", + Description: "Show the current configuration", + }, + { + Command: "list", + Description: "List available options", + }, + } + + // Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed + if !slices.Equal(currentCommands, commands) { + logger.InfoC("telegram", "Updating bot commands") + + err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{ + Commands: commands, + Scope: tu.ScopeAllPrivateChats(), + }) + if err != nil { + return fmt.Errorf("set commands: %w", err) + } + } else { + logger.InfoC("telegram", "Bot commands up to date") + } + return nil } From d1a66cbf50ae2261f6c605f07f24a7e12ee1b905 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Mon, 16 Feb 2026 13:24:21 +0200 Subject: [PATCH 13/82] feat(telegram): Fix text --- pkg/channels/telegram.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index a1a57a533..52724c600 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -166,7 +166,7 @@ func (c *TelegramChannel) initBotCommands(ctx context.Context) error { }, { Command: "show", - Description: "Show the current configuration", + Description: "Show current configuration", }, { Command: "list", From 1b1e472df25cf91f0a8ceb53d347ab5b4888b27a Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Mon, 16 Feb 2026 13:25:24 +0200 Subject: [PATCH 14/82] feat(telegram): Changed command scope --- pkg/channels/telegram.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 52724c600..49c359ef5 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -149,7 +149,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { func (c *TelegramChannel) initBotCommands(ctx context.Context) error { currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{ - Scope: tu.ScopeAllPrivateChats(), + Scope: tu.ScopeDefault(), }) if err != nil { return fmt.Errorf("get commands: %w", err) @@ -180,7 +180,7 @@ func (c *TelegramChannel) initBotCommands(ctx context.Context) error { err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{ Commands: commands, - Scope: tu.ScopeAllPrivateChats(), + Scope: tu.ScopeDefault(), }) if err != nil { return fmt.Errorf("set commands: %w", err) From 77d26e5ce32adebf31eb773d1dcb9ebabf57ae65 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 19:33:31 +0800 Subject: [PATCH 15/82] fix(mcp): return aggregated error when all servers fail to connect - Add errors.Join to return aggregated error when all enabled MCP servers fail - Track enabled server count separately from total configured servers - Return error only when all servers fail, not for partial failures - Improve logging with accurate server counts (enabled vs connected) - Maintains fault tolerance: partial failures don't stop initialization --- pkg/mcp/manager.go | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index d6ca28f76..be941ec23 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -3,6 +3,7 @@ package mcp import ( "bufio" "context" + "errors" "fmt" "net/http" "os" @@ -24,12 +25,12 @@ type headerTransport struct { func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original req = req.Clone(req.Context()) - + // Add custom headers for key, value := range t.headers { req.Header.Set(key, value) } - + // Use the base transport base := t.base if base == nil { @@ -129,6 +130,7 @@ func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error var wg sync.WaitGroup errs := make(chan error, len(cfg.Tools.MCP.Servers)) + enabledCount := 0 for name, serverCfg := range cfg.Tools.MCP.Servers { if !serverCfg.Enabled { @@ -139,6 +141,7 @@ func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error continue } + enabledCount++ wg.Add(1) go func(name string, serverCfg config.MCPServerConfig) { defer wg.Done() @@ -163,20 +166,32 @@ func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error allErrors = append(allErrors, err) } + connectedCount := len(m.GetServers()) + + // If all enabled servers failed to connect, return aggregated error + if enabledCount > 0 && connectedCount == 0 { + logger.ErrorCF("mcp", "All MCP servers failed to connect", + map[string]interface{}{ + "failed": len(allErrors), + "total": enabledCount, + }) + return errors.Join(allErrors...) + } + if len(allErrors) > 0 { logger.WarnCF("mcp", "Some MCP servers failed to connect", map[string]interface{}{ - "failed": len(allErrors), - "total": len(cfg.Tools.MCP.Servers), + "failed": len(allErrors), + "connected": connectedCount, + "total": enabledCount, }) - // Don't fail completely if some servers fail to connect + // Don't fail completely if some servers successfully connected } - connectedCount := len(m.GetServers()) logger.InfoCF("mcp", "MCP server initialization complete", map[string]interface{}{ "connected": connectedCount, - "total": len(cfg.Tools.MCP.Servers), + "total": enabledCount, }) return nil @@ -223,11 +238,11 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP "server": name, "url": cfg.URL, }) - + sseTransport := &mcp.StreamableClientTransport{ Endpoint: cfg.URL, } - + // Add custom headers if provided if len(cfg.Headers) > 0 { // Create a custom HTTP client with header-injecting transport @@ -243,7 +258,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP "header_count": len(cfg.Headers), }) } - + transport = sseTransport case "stdio": if cfg.Command == "" { @@ -259,7 +274,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP // Set environment variables env := cmd.Environ() - + // Load environment variables from file if specified if cfg.EnvFile != "" { envVars, err := loadEnvFile(cfg.EnvFile) @@ -276,14 +291,14 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP "var_count": len(envVars), }) } - + // Environment variables from config override those from file if len(cfg.Env) > 0 { for k, v := range cfg.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } } - + // Set environment if we added any variables if len(env) > len(cmd.Environ()) { cmd.Env = env From a4265b3f163ef18cd664cffcdd5e18333fddc530 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 19:38:27 +0800 Subject: [PATCH 16/82] fix(mcp): resolve relative envFile paths against workspace directory - Resolve relative envFile paths relative to workspace instead of CWD - Add filepath import for path operations - Pass workspace path to goroutines for path resolution - Improves portability in Docker environments where CWD may vary - Absolute envFile paths continue to work as before --- pkg/mcp/manager.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index be941ec23..8449486f5 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "strings" "sync" @@ -128,6 +129,9 @@ func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error "count": len(cfg.Tools.MCP.Servers), }) + // Get workspace path for resolving relative envFile paths + workspacePath := cfg.WorkspacePath() + var wg sync.WaitGroup errs := make(chan error, len(cfg.Tools.MCP.Servers)) enabledCount := 0 @@ -143,9 +147,14 @@ func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error enabledCount++ wg.Add(1) - go func(name string, serverCfg config.MCPServerConfig) { + go func(name string, serverCfg config.MCPServerConfig, workspace string) { defer wg.Done() + // Resolve relative envFile paths relative to workspace + if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) { + serverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile) + } + if err := m.ConnectServer(ctx, name, serverCfg); err != nil { logger.ErrorCF("mcp", "Failed to connect to MCP server", map[string]interface{}{ @@ -154,7 +163,7 @@ func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error }) errs <- fmt.Errorf("failed to connect to server %s: %w", name, err) } - }(name, serverCfg) + }(name, serverCfg, workspacePath) } wg.Wait() From a026d56c0f7ce097e645693018ebff7965c32891 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 19:40:14 +0800 Subject: [PATCH 17/82] chore(deps): consolidate indirect require for uritemplate - Move standalone indirect require line into existing require block - Maintain alphabetical ordering of dependencies - Keep module file stable and avoid churn --- go.mod | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 4118e0767..705767f89 100644 --- a/go.mod +++ b/go.mod @@ -27,8 +27,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -require github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect @@ -50,6 +48,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.7 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect From 20f8bb200b84a2350bc38387cad53816de281ef0 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 19:43:05 +0800 Subject: [PATCH 18/82] refactor(tools): use MCPManager interface in NewMCPTool constructor - Change NewMCPTool to accept MCPManager interface instead of concrete *mcp.Manager - Remove unused mcpPkg import from mcp_tool.go - Remove newMCPToolForTest helper function as NewMCPTool now accepts interface - Update all tests to use NewMCPTool directly with MockMCPManager - Improves testability and follows dependency inversion principle --- pkg/tools/mcp_tool.go | 3 +-- pkg/tools/mcp_tool_test.go | 33 ++++++++++++--------------------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index e08a526ca..adc06ec23 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" - mcpPkg "github.com/sipeed/picoclaw/pkg/mcp" ) // MCPManager defines the interface for MCP manager operations @@ -23,7 +22,7 @@ type MCPTool struct { } // NewMCPTool creates a new MCP tool wrapper -func NewMCPTool(manager *mcpPkg.Manager, serverName string, tool *mcp.Tool) *MCPTool { +func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { return &MCPTool{ manager: manager, serverName: serverName, diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index 3be5d95a0..597bbb52b 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -26,15 +26,6 @@ func (m *MockMCPManager) CallTool(ctx context.Context, serverName, toolName stri }, nil } -// newMCPToolForTest creates an MCP tool for testing with mock manager -func newMCPToolForTest(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { - return &MCPTool{ - manager: manager, - serverName: serverName, - tool: tool, - } -} - // TestNewMCPTool verifies MCP tool creation func TestNewMCPTool(t *testing.T) { manager := &MockMCPManager{} @@ -48,11 +39,11 @@ func TestNewMCPTool(t *testing.T) { "type": "string", "description": "Test input", }, + }, }, - }, - } + } - mcpTool := newMCPToolForTest(manager, "test_server", tool) + mcpTool := NewMCPTool(manager, "test_server", tool) if mcpTool == nil { t.Fatal("NewMCPTool should not return nil") @@ -95,7 +86,7 @@ func TestMCPTool_Name(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := &MockMCPManager{} tool := &mcp.Tool{Name: tt.toolName} - mcpTool := newMCPToolForTest(manager, tt.serverName, tool) + mcpTool := NewMCPTool(manager, tt.serverName, tool) result := mcpTool.Name() if result != tt.expected { @@ -134,7 +125,7 @@ func TestMCPTool_Description(t *testing.T) { Name: "test_tool", Description: tt.toolDescription, } - mcpTool := newMCPToolForTest(manager, tt.serverName, tool) + mcpTool := NewMCPTool(manager, tt.serverName, tool) result := mcpTool.Description() @@ -182,7 +173,7 @@ func TestMCPTool_Parameters(t *testing.T) { Name: "test_tool", InputSchema: tt.inputSchema, } - mcpTool := newMCPToolForTest(manager, "test_server", tool) + mcpTool := NewMCPTool(manager, "test_server", tool) params := mcpTool.Parameters() @@ -222,7 +213,7 @@ func TestMCPTool_Execute_Success(t *testing.T) { Name: "search_repos", Description: "Search GitHub repositories", } - mcpTool := newMCPToolForTest(manager, "github", tool) + mcpTool := NewMCPTool(manager, "github", tool) ctx := context.Background() args := map[string]interface{}{ @@ -251,7 +242,7 @@ func TestMCPTool_Execute_ManagerError(t *testing.T) { } tool := &mcp.Tool{Name: "test_tool"} - mcpTool := newMCPToolForTest(manager, "test_server", tool) + mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() result := mcpTool.Execute(ctx, map[string]interface{}{}) @@ -284,7 +275,7 @@ func TestMCPTool_Execute_ServerError(t *testing.T) { } tool := &mcp.Tool{Name: "test_tool"} - mcpTool := newMCPToolForTest(manager, "test_server", tool) + mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() result := mcpTool.Execute(ctx, map[string]interface{}{}) @@ -319,7 +310,7 @@ func TestMCPTool_Execute_MultipleContent(t *testing.T) { } tool := &mcp.Tool{Name: "multi_output"} - mcpTool := newMCPToolForTest(manager, "test_server", tool) + mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() result := mcpTool.Execute(ctx, map[string]interface{}{}) @@ -407,7 +398,7 @@ func TestExtractContentText_EmptyContent(t *testing.T) { func TestMCPTool_InterfaceCompliance(t *testing.T) { manager := &MockMCPManager{} tool := &mcp.Tool{Name: "test"} - mcpTool := newMCPToolForTest(manager, "test_server", tool) + mcpTool := NewMCPTool(manager, "test_server", tool) // Verify it implements Tool interface var _ Tool = mcpTool @@ -431,7 +422,7 @@ func TestMCPTool_Parameters_MapSchema(t *testing.T) { Name: "test_tool", InputSchema: schema, } - mcpTool := newMCPToolForTest(manager, "test_server", tool) + mcpTool := NewMCPTool(manager, "test_server", tool) params := mcpTool.Parameters() From 02c1792015baf4ed0e0f23c7f78e4373fa316959 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 19:50:00 +0800 Subject: [PATCH 19/82] fix(tools): preserve MCP tool InputSchema via JSON marshal/unmarshal - Handle json.RawMessage and []byte types by direct unmarshal - Use JSON marshal/unmarshal for struct types to preserve schema - Add test case for json.RawMessage schema - Fixes issue where non-map schemas returned empty object This fixes GitHub Copilot feedback that Parameters() was dropping tool schema when InputSchema wasn't already map[string]interface{} --- pkg/tools/mcp_tool.go | 72 ++++++++++++++++++++++++++++---------- pkg/tools/mcp_tool_test.go | 55 +++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 25 deletions(-) diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index adc06ec23..c29ab8525 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -2,6 +2,7 @@ package tools import ( "context" + "encoding/json" "fmt" "strings" @@ -51,26 +52,61 @@ func (t *MCPTool) Parameters() map[string]interface{} { // The InputSchema is already a JSON Schema object schema := t.tool.InputSchema - // Convert to map[string]interface{} for compatibility - result := make(map[string]interface{}) - - // Use reflection to convert the schema - // The schema should already be in the correct format - if schema != nil { - // Attempt to convert directly - if schemaMap, ok := schema.(map[string]interface{}); ok { - return schemaMap + // Handle nil schema + if schema == nil { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, } + } - // Otherwise, build it manually - result["type"] = "object" - result["properties"] = map[string]interface{}{} - result["required"] = []string{} - } else { - // Default schema when nil - result["type"] = "object" - result["properties"] = map[string]interface{}{} - result["required"] = []string{} + // Try direct conversion first (fast path) + if schemaMap, ok := schema.(map[string]interface{}); ok { + return schemaMap + } + + // Handle json.RawMessage and []byte - unmarshal directly + var jsonData []byte + if rawMsg, ok := schema.(json.RawMessage); ok { + jsonData = rawMsg + } else if bytes, ok := schema.([]byte); ok { + jsonData = bytes + } + + if jsonData != nil { + var result map[string]interface{} + if err := json.Unmarshal(jsonData, &result); err == nil { + return result + } + // Fallback on error + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, + } + } + + // For other types (structs, etc.), convert via JSON marshal/unmarshal + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + // Fallback to empty schema if marshaling fails + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, + } + } + + var result map[string]interface{} + if err := json.Unmarshal(jsonData, &result); err != nil { + // Fallback to empty schema if unmarshaling fails + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, + } } return result diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index 597bbb52b..92fb66234 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -141,9 +141,11 @@ func TestMCPTool_Description(t *testing.T) { // TestMCPTool_Parameters verifies parameter schema conversion func TestMCPTool_Parameters(t *testing.T) { tests := []struct { - name string - inputSchema interface{} - expectType string + name string + inputSchema interface{} + expectType string + checkProperty string + expectProperty bool }{ { name: "map schema", @@ -157,12 +159,35 @@ func TestMCPTool_Parameters(t *testing.T) { }, "required": []string{"query"}, }, - expectType: "object", + expectType: "object", + checkProperty: "query", + expectProperty: true, }, { - name: "nil schema", - inputSchema: nil, - expectType: "object", + name: "nil schema", + inputSchema: nil, + expectType: "object", + expectProperty: false, + }, + { + name: "json.RawMessage schema", + inputSchema: []byte(`{ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Repository name" + }, + "stars": { + "type": "integer", + "description": "Minimum stars" + } + }, + "required": ["repo"] + }`), + expectType: "object", + checkProperty: "repo", + expectProperty: true, }, } @@ -184,6 +209,22 @@ func TestMCPTool_Parameters(t *testing.T) { if params["type"] != tt.expectType { t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"]) } + + // Check if property exists when expected + if tt.checkProperty != "" { + properties, ok := params["properties"].(map[string]interface{}) + if !ok && tt.expectProperty { + t.Errorf("Expected properties to be a map") + return + } + if ok { + _, hasProperty := properties[tt.checkProperty] + if hasProperty != tt.expectProperty { + t.Errorf("Expected property '%s' existence: %v, got: %v", + tt.checkProperty, tt.expectProperty, hasProperty) + } + } + } }) } } From aed7296c0d95ff88683cce0b6733510fd5895f3a Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 19:53:15 +0800 Subject: [PATCH 20/82] fix(agent): tie MCP connections to agent lifecycle context - Defer MCP server initialization to Run() using agent's context - Add mcpConfig and mcpInitOnce fields to AgentLoop - Use sync.Once to ensure MCP loads exactly once with proper context - Prevents orphaned subprocesses and resource leaks on cancellation This fixes GitHub Copilot feedback that MCP connections with context.Background() won't terminate when the agent stops, causing potential resource leaks and orphaned stdio/SSE connections. --- pkg/agent/loop.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 9349f86f3..afd8e8481 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -42,7 +42,9 @@ type AgentLoop struct { state *state.Manager contextBuilder *ContextBuilder tools *tools.ToolRegistry - mcpManager *mcp.Manager // MCP server manager for resource cleanup + mcpManager *mcp.Manager // MCP server manager for resource cleanup + mcpConfig *config.Config // Config for lazy MCP initialization + mcpInitOnce sync.Once // Ensures MCP is initialized only once running atomic.Bool summarizing sync.Map // Tracks which sessions are currently being summarized channelManager *channels.Manager @@ -129,15 +131,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers restrict := cfg.Agents.Defaults.RestrictToWorkspace - // Initialize MCP Manager and load servers + // Create MCP Manager (actual server loading deferred to Run()) + // This ensures MCP connections use the agent's lifecycle context mcpManager := mcp.NewManager() - ctx := context.Background() - if err := mcpManager.LoadFromConfig(ctx, cfg); err != nil { - logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", - map[string]interface{}{ - "error": err.Error(), - }) - } // Create tool registry for main agent toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus, mcpManager) @@ -177,6 +173,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers contextBuilder: contextBuilder, tools: toolsRegistry, mcpManager: mcpManager, + mcpConfig: cfg, // Store config for lazy initialization in Run() summarizing: sync.Map{}, } } @@ -184,6 +181,17 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) + // Initialize MCP servers using the agent's lifecycle context + // This ensures MCP connections are cancelled when the agent stops + al.mcpInitOnce.Do(func() { + if err := al.mcpManager.LoadFromConfig(ctx, al.mcpConfig); err != nil { + logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", + map[string]interface{}{ + "error": err.Error(), + }) + } + }) + for al.running.Load() { select { case <-ctx.Done(): From 2318232b71737ac100495ec86fdf0833fe379c4c Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 19:56:00 +0800 Subject: [PATCH 21/82] fix(agent): ensure MCP cleanup on all Run() exit paths - Add defer in Run() to guarantee MCP connection cleanup - Handles both normal termination and context cancellation - Prevents resource leaks when Run() exits via ctx.Done() - MCP Manager.Close() is idempotent, safe to call from both defer and Stop() This fixes GitHub Copilot feedback that MCP cleanup only happened in Stop() but Run() could return on ctx.Done() without cleanup, causing subprocess/session leaks on normal cancellation shutdown. --- pkg/agent/loop.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index afd8e8481..a18e962fa 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -42,9 +42,9 @@ type AgentLoop struct { state *state.Manager contextBuilder *ContextBuilder tools *tools.ToolRegistry - mcpManager *mcp.Manager // MCP server manager for resource cleanup - mcpConfig *config.Config // Config for lazy MCP initialization - mcpInitOnce sync.Once // Ensures MCP is initialized only once + mcpManager *mcp.Manager // MCP server manager for resource cleanup + mcpConfig *config.Config // Config for lazy MCP initialization + mcpInitOnce sync.Once // Ensures MCP is initialized only once running atomic.Bool summarizing sync.Map // Tracks which sessions are currently being summarized channelManager *channels.Manager @@ -192,6 +192,18 @@ func (al *AgentLoop) Run(ctx context.Context) error { } }) + // Ensure MCP connections are cleaned up on all exit paths + defer func() { + if al.mcpManager != nil { + if err := al.mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]interface{}{ + "error": err.Error(), + }) + } + } + }() + for al.running.Load() { select { case <-ctx.Done(): From 0f6fadb445041aba56d04b764c6a3776b25427ff Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Mon, 16 Feb 2026 20:00:37 +0800 Subject: [PATCH 22/82] fix(agent): register MCP tools after server initialization Critical bug fix: - MCP tools were never registered because servers loaded in Run() but tool registration happened in NewAgentLoop() with empty manager - Move MCP tool registration from createToolRegistry to Run() - Register MCP tools for both main agent and subag after successful server loading - Add subagentManager field to AgentLoop for dynamic registration - Add tool_count logging for better observability This ensures MCP tools are properly available to both agent and subagents. --- pkg/agent/loop.go | 106 ++++++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 45 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a18e962fa..c4d7c4ae7 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -32,22 +32,23 @@ import ( ) type AgentLoop struct { - bus *bus.MessageBus - provider providers.LLMProvider - workspace string - model string - contextWindow int // Maximum context window size in tokens - maxIterations int - sessions *session.SessionManager - state *state.Manager - contextBuilder *ContextBuilder - tools *tools.ToolRegistry - mcpManager *mcp.Manager // MCP server manager for resource cleanup - mcpConfig *config.Config // Config for lazy MCP initialization - mcpInitOnce sync.Once // Ensures MCP is initialized only once - running atomic.Bool - summarizing sync.Map // Tracks which sessions are currently being summarized - channelManager *channels.Manager + bus *bus.MessageBus + provider providers.LLMProvider + workspace string + model string + contextWindow int // Maximum context window size in tokens + maxIterations int + sessions *session.SessionManager + state *state.Manager + contextBuilder *ContextBuilder + tools *tools.ToolRegistry + mcpManager *mcp.Manager // MCP server manager for resource cleanup + mcpConfig *config.Config // Config for lazy MCP initialization + mcpInitOnce sync.Once // Ensures MCP is initialized only once + subagentManager *tools.SubagentManager // Subagent manager for MCP tool registration + running atomic.Bool + summarizing sync.Map // Tracks which sessions are currently being summarized + channelManager *channels.Manager } // processOptions configures how a message is processed @@ -105,22 +106,8 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg }) registry.Register(messageTool) - // Register MCP tools from all connected servers - if mcpManager != nil { - servers := mcpManager.GetServers() - for serverName, conn := range servers { - for _, tool := range conn.Tools { - mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) - registry.Register(mcpTool) - logger.DebugCF("agent", "Registered MCP tool", - map[string]interface{}{ - "server": serverName, - "tool": tool.Name, - "name": mcpTool.Name(), - }) - } - } - } + // Note: MCP tools are registered dynamically in Run() after servers are loaded + // This ensures we use the agent's lifecycle context for MCP connections return registry } @@ -162,19 +149,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers contextBuilder.SetToolsRegistry(toolsRegistry) return &AgentLoop{ - bus: msgBus, - provider: provider, - workspace: workspace, - model: cfg.Agents.Defaults.Model, - contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization - maxIterations: cfg.Agents.Defaults.MaxToolIterations, - sessions: sessionsManager, - state: stateManager, - contextBuilder: contextBuilder, - tools: toolsRegistry, - mcpManager: mcpManager, - mcpConfig: cfg, // Store config for lazy initialization in Run() - summarizing: sync.Map{}, + bus: msgBus, + provider: provider, + workspace: workspace, + model: cfg.Agents.Defaults.Model, + contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization + maxIterations: cfg.Agents.Defaults.MaxToolIterations, + sessions: sessionsManager, + state: stateManager, + contextBuilder: contextBuilder, + tools: toolsRegistry, + mcpManager: mcpManager, + mcpConfig: cfg, // Store config for lazy initialization in Run() + subagentManager: subagentManager, + summarizing: sync.Map{}, } } @@ -189,6 +177,34 @@ func (al *AgentLoop) Run(ctx context.Context) error { map[string]interface{}{ "error": err.Error(), }) + } else { + // Register for both main agent and subagents + servers := al.mcpManager.GetServers() + toolCount := 0 + for serverName, conn := range servers { + for _, tool := range conn.Tools { + mcpTool := tools.NewMCPTool(al.mcpManager, serverName, tool) + al.tools.Register(mcpTool) + + // Also register for subagent + if al.subagentManager != nil { + al.subagentManager.RegisterTool(tools.NewMCPTool(al.mcpManager, serverName, tool)) + } + + toolCount++ + logger.DebugCF("agent", "Registered MCP tool", + map[string]interface{}{ + "server": serverName, + "tool": tool.Name, + "name": mcpTool.Name(), + }) + } + } + logger.InfoCF("agent", "MCP tools registered successfully", + map[string]interface{}{ + "server_count": len(servers), + "tool_count": toolCount, + }) } }) From 6892d006d680902b32e07743434101db4cafec92 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Tue, 17 Feb 2026 10:39:39 +0800 Subject: [PATCH 23/82] perf(agent): reduce memory footprint by storing minimal MCP dependencies Replace full *config.Config reference with config.MCPConfig value type in AgentLoop to allow garbage collection of unused configuration data. Changes: - AgentLoop now stores only MCPConfig and workspacePath (minimal deps) - Add mcp.Manager.LoadFromMCPConfig() for minimal dependency version - Keep LoadFromConfig() for backward compatibility - Full Config object can be GC'd after NewAgentLoop() returns This optimization reduces memory usage by not holding references to unused channel, provider, gateway, and device configurations. --- pkg/agent/loop.go | 8 +++++--- pkg/mcp/manager.go | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index c4d7c4ae7..69aa8759a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -43,7 +43,8 @@ type AgentLoop struct { contextBuilder *ContextBuilder tools *tools.ToolRegistry mcpManager *mcp.Manager // MCP server manager for resource cleanup - mcpConfig *config.Config // Config for lazy MCP initialization + mcpConfig config.MCPConfig // MCP config for lazy initialization (minimal dependency) + workspacePath string // Workspace path for resolving relative envFile paths mcpInitOnce sync.Once // Ensures MCP is initialized only once subagentManager *tools.SubagentManager // Subagent manager for MCP tool registration running atomic.Bool @@ -160,7 +161,8 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers contextBuilder: contextBuilder, tools: toolsRegistry, mcpManager: mcpManager, - mcpConfig: cfg, // Store config for lazy initialization in Run() + mcpConfig: cfg.Tools.MCP, // Store only MCP config (minimal dependency) + workspacePath: workspace, // Store workspace path for envFile resolution subagentManager: subagentManager, summarizing: sync.Map{}, } @@ -172,7 +174,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { // Initialize MCP servers using the agent's lifecycle context // This ensures MCP connections are cancelled when the agent stops al.mcpInitOnce.Do(func() { - if err := al.mcpManager.LoadFromConfig(ctx, al.mcpConfig); err != nil { + if err := al.mcpManager.LoadFromMCPConfig(ctx, al.mcpConfig, al.workspacePath); err != nil { logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", map[string]interface{}{ "error": err.Error(), diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 8449486f5..833755cbd 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -114,29 +114,32 @@ func NewManager() *Manager { // LoadFromConfig loads MCP servers from configuration func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error { - if !cfg.Tools.MCP.Enabled { + return m.LoadFromMCPConfig(ctx, cfg.Tools.MCP, cfg.WorkspacePath()) +} + +// LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path. +// This is the minimal dependency version that doesn't require the full Config object. +func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig, workspacePath string) error { + if !mcpCfg.Enabled { logger.InfoCF("mcp", "MCP integration is disabled", nil) return nil } - if len(cfg.Tools.MCP.Servers) == 0 { + if len(mcpCfg.Servers) == 0 { logger.InfoCF("mcp", "No MCP servers configured", nil) return nil } logger.InfoCF("mcp", "Initializing MCP servers", map[string]interface{}{ - "count": len(cfg.Tools.MCP.Servers), + "count": len(mcpCfg.Servers), }) - // Get workspace path for resolving relative envFile paths - workspacePath := cfg.WorkspacePath() - var wg sync.WaitGroup - errs := make(chan error, len(cfg.Tools.MCP.Servers)) + errs := make(chan error, len(mcpCfg.Servers)) enabledCount := 0 - for name, serverCfg := range cfg.Tools.MCP.Servers { + for name, serverCfg := range mcpCfg.Servers { if !serverCfg.Enabled { logger.DebugCF("mcp", "Skipping disabled server", map[string]interface{}{ From 4113190c2a981e9ac3f0ffd9738e6985797976c3 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Tue, 17 Feb 2026 10:41:29 +0800 Subject: [PATCH 24/82] chore(config): remove example MCP servers from default config Remove pre-populated example servers (filesystem, github, brave-search, postgres) from DefaultConfig() to reduce memory footprint per instance. Changes: - Set MCP.Servers to empty map instead of 4 example servers - Reduces default config size by ~500 bytes per instance - Users should add MCP servers via config.json or documentation Example configurations are still available in: - README.md MCP section - config.example.json - Official MCP documentation This optimization benefits deployments with many agent instances. --- pkg/config/config.go | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index ddc376645..d1dcffd03 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -353,35 +353,7 @@ func DefaultConfig() *Config { }, MCP: MCPConfig{ Enabled: false, - Servers: map[string]MCPServerConfig{ - "filesystem": { - Enabled: false, - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, - Env: map[string]string{}, - }, - "github": { - Enabled: false, - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-github"}, - Env: map[string]string{ - "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN", - }, - }, - "brave-search": { - Enabled: false, - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-brave-search"}, - Env: map[string]string{ - "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY", - }, - }, - "postgres": { - Enabled: false, - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname"}, - }, - }, + Servers: map[string]MCPServerConfig{}, }, }, Heartbeat: HeartbeatConfig{ From e38364b08a60f9d586c3191ebcdf208b3eef07db Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Tue, 17 Feb 2026 10:57:38 +0800 Subject: [PATCH 25/82] build(docker): migrate full image from Debian to Alpine base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace node:24-bookworm-slim with node:24-alpine3.23 to reduce image size and improve build efficiency. Changes: - Base image: node:24-bookworm-slim → node:24-alpine3.23 - Package manager: apt-get → apk - Package names: python3-pip → py3-pip - Remove python3-venv (included in Alpine Python3) - Use apk --no-cache for cleaner image layers Expected benefits: - Reduce base image size by ~100-200MB (30-40% reduction) - Faster image pulls and container startup - Full MCP support maintained (Node.js, Python, uv) Estimated final image size: ~600-700MB (vs ~800MB before) --- Dockerfile.full | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Dockerfile.full b/Dockerfile.full index aebcdafe6..30e1680d5 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -18,17 +18,15 @@ RUN make build # ============================================================ # Stage 2: Node.js-based runtime with full MCP support # ============================================================ -FROM node:24-bookworm-slim +FROM node:24-alpine3.23 # Install runtime dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN apk add --no-cache \ ca-certificates \ curl \ git \ python3 \ - python3-pip \ - python3-venv \ - && rm -rf /var/lib/apt/lists/* + py3-pip # Install uv and symlink to system path RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ From 47533a00cd5ca796aeb1a43d1ed80f93fdaf4d5e Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Thu, 19 Feb 2026 18:53:24 +0800 Subject: [PATCH 26/82] style: format code with gofmt --- pkg/agent/loop.go | 4 ++-- pkg/mcp/manager_test.go | 14 +++++++------- pkg/tools/mcp_tool_test.go | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 69aa8759a..f150534b8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -161,8 +161,8 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers contextBuilder: contextBuilder, tools: toolsRegistry, mcpManager: mcpManager, - mcpConfig: cfg.Tools.MCP, // Store only MCP config (minimal dependency) - workspacePath: workspace, // Store workspace path for envFile resolution + mcpConfig: cfg.Tools.MCP, // Store only MCP config (minimal dependency) + workspacePath: workspace, // Store workspace path for envFile resolution subagentManager: subagentManager, summarizing: sync.Map{}, } diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index e888d8764..1c69e75f2 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -134,32 +134,32 @@ func TestEnvFilePriority(t *testing.T) { // Create a temporary .env file tmpDir := t.TempDir() envFile := filepath.Join(tmpDir, ".env") - + envContent := `API_KEY=from_file DATABASE_URL=from_file SHARED_VAR=from_file` - + if err := os.WriteFile(envFile, []byte(envContent), 0644); err != nil { t.Fatalf("Failed to create .env file: %v", err) } - + // Load envFile envVars, err := loadEnvFile(envFile) if err != nil { t.Fatalf("Failed to load env file: %v", err) } - + // Verify envFile variables if envVars["API_KEY"] != "from_file" { t.Errorf("Expected API_KEY=from_file, got %s", envVars["API_KEY"]) } - + // Simulate config.Env overriding envFile configEnv := map[string]string{ "SHARED_VAR": "from_config", "NEW_VAR": "from_config", } - + // Merge: envFile first, then config overrides merged := make(map[string]string) for k, v := range envVars { @@ -168,7 +168,7 @@ SHARED_VAR=from_file` for k, v := range configEnv { merged[k] = v } - + // Verify priority: config.Env should override envFile if merged["SHARED_VAR"] != "from_config" { t.Errorf("Expected SHARED_VAR=from_config (config should override file), got %s", merged["SHARED_VAR"]) diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index 92fb66234..580abc2ba 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -39,9 +39,9 @@ func TestNewMCPTool(t *testing.T) { "type": "string", "description": "Test input", }, - }, }, - } + }, + } mcpTool := NewMCPTool(manager, "test_server", tool) From ffa01986ce1513846f42d55bd8aab9d0bc3cd8fd Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Thu, 19 Feb 2026 19:26:02 +0800 Subject: [PATCH 27/82] fix(agent): scope MCP manager cleanup to successful initialization Move defer cleanup inside else block to only clean up when MCP servers are successfully initialized. This prevents unnecessary cleanup attempts when LoadFromMCPConfig fails. Addresses Copilot code review feedback. --- pkg/agent/loop.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d2d49e5b7..e5a1465eb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -150,6 +150,16 @@ func (al *AgentLoop) Run(ctx context.Context) error { "error": err.Error(), }) } else { + // Ensure MCP connections are cleaned up on exit, only if initialization succeeded + defer func() { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]interface{}{ + "error": err.Error(), + }) + } + }() + // Register MCP tools for all agents servers := mcpManager.GetServers() toolCount := 0 @@ -179,16 +189,6 @@ func (al *AgentLoop) Run(ctx context.Context) error { "tool_count": toolCount, }) } - - // Ensure MCP connections are cleaned up on exit - defer func() { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]interface{}{ - "error": err.Error(), - }) - } - }() } for al.running.Load() { From dea381c38567fc242df0f3fd8d64172134712f67 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Thu, 19 Feb 2026 19:31:13 +0800 Subject: [PATCH 28/82] improve(agent): clarify MCP tool registration logging Separate tool counting metrics for better clarity: - unique_tools: number of distinct MCP tools - total_registrations: total tool registrations across all agents - agent_count: number of agents receiving the tools Previously, tool_count was misleading as it showed total registrations, making it appear that more unique tools were registered than actually exist. Addresses Copilot code review feedback. --- pkg/agent/loop.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e5a1465eb..87b47f4ad 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -162,8 +162,12 @@ func (al *AgentLoop) Run(ctx context.Context) error { // Register MCP tools for all agents servers := mcpManager.GetServers() - toolCount := 0 + uniqueTools := 0 + totalRegistrations := 0 + agentCount := len(al.registry.ListAgentIDs()) + for serverName, conn := range servers { + uniqueTools += len(conn.Tools) for _, tool := range conn.Tools { for _, agentID := range al.registry.ListAgentIDs() { agent, ok := al.registry.GetAgent(agentID) @@ -172,7 +176,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) agent.Tools.Register(mcpTool) - toolCount++ + totalRegistrations++ logger.DebugCF("agent", "Registered MCP tool", map[string]interface{}{ "agent_id": agentID, @@ -185,8 +189,10 @@ func (al *AgentLoop) Run(ctx context.Context) error { } logger.InfoCF("agent", "MCP tools registered successfully", map[string]interface{}{ - "server_count": len(servers), - "tool_count": toolCount, + "server_count": len(servers), + "unique_tools": uniqueTools, + "total_registrations": totalRegistrations, + "agent_count": agentCount, }) } } From f0ce26ff2bfdf0f3a05743ca43594d022689760d Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Thu, 19 Feb 2026 19:43:48 +0800 Subject: [PATCH 29/82] style(config): use snake_case for EnvFile JSON field name Change 'envFile' to 'env_file' to maintain consistency with the rest of the codebase which uses snake_case for JSON field names (e.g., 'api_key', 'api_base', 'max_results', 'exec_timeout_minutes'). Addresses Copilot code review feedback. --- pkg/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index bb3d6c3bc..53b0c6e1d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -333,7 +333,7 @@ type MCPServerConfig struct { // Env are environment variables to set for the server process (stdio only) Env map[string]string `json:"env,omitempty"` // EnvFile is the path to a file containing environment variables (stdio only) - EnvFile string `json:"envFile,omitempty"` + EnvFile string `json:"env_file,omitempty"` // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set) Type string `json:"type,omitempty"` // URL is used for SSE/HTTP transport From 757741476181b6ed2b39cf1626c472eb069fe631 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Thu, 19 Feb 2026 19:45:15 +0800 Subject: [PATCH 30/82] fix(mcp): ensure proper environment variable override semantics Use a map to merge environment variables with guaranteed override behavior. Config variables (cfg.Env) now properly override file variables (envFile), which in turn override parent process environment. Previously, simply appending to a slice could result in duplicate variables, and while most systems use the last occurrence, this behavior is not guaranteed and could lead to unexpected results. Addresses Copilot code review feedback. --- pkg/mcp/manager.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 833755cbd..923d01f3c 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -284,8 +284,16 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP // Create command with context cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) - // Set environment variables - env := cmd.Environ() + // Build environment variables with proper override semantics + // Use a map to ensure config variables override file variables + envMap := make(map[string]string) + + // Start with parent process environment + for _, e := range cmd.Environ() { + if idx := strings.Index(e, "="); idx > 0 { + envMap[e[:idx]] = e[idx+1:] + } + } // Load environment variables from file if specified if cfg.EnvFile != "" { @@ -294,7 +302,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) } for k, v := range envVars { - env = append(env, fmt.Sprintf("%s=%s", k, v)) + envMap[k] = v } logger.DebugCF("mcp", "Loaded environment variables from file", map[string]interface{}{ @@ -305,16 +313,16 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP } // Environment variables from config override those from file - if len(cfg.Env) > 0 { - for k, v := range cfg.Env { - env = append(env, fmt.Sprintf("%s=%s", k, v)) - } + for k, v := range cfg.Env { + envMap[k] = v } - // Set environment if we added any variables - if len(env) > len(cmd.Environ()) { - cmd.Env = env + // Convert map to slice + env := make([]string, 0, len(envMap)) + for k, v := range envMap { + env = append(env, fmt.Sprintf("%s=%s", k, v)) } + cmd.Env = env transport = &mcp.CommandTransport{Command: cmd} default: From f1b798434d2f098d26b996a5f50995ffda9af1de Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Thu, 19 Feb 2026 19:47:05 +0800 Subject: [PATCH 31/82] fix(mcp): prevent race condition between CallTool and Close Add a closed flag to the Manager struct to prevent CallTool from accessing server connections after Close has been called. The flag is checked within the RLock in CallTool to ensure thread-safety. Previously, CallTool could obtain a server reference using RLock, then that reference could be closed by Close() running concurrently, leading to use-after-close errors. Now: 1. CallTool checks the closed flag before accessing servers 2. Close sets the closed flag before closing connections 3. CallTool directly accesses m.servers within the lock instead of using GetServer() to avoid releasing the lock prematurely This ensures CallTool will not use a server connection that is being closed or has been closed. Addresses Copilot code review feedback. --- pkg/mcp/manager.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 923d01f3c..1fc253774 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -103,6 +103,7 @@ type ServerConnection struct { type Manager struct { servers map[string]*ServerConnection mu sync.RWMutex + closed bool } // NewManager creates a new MCP manager @@ -403,7 +404,14 @@ func (m *Manager) GetServer(name string) (*ServerConnection, bool) { // CallTool calls a tool on a specific server func (m *Manager) CallTool(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { - conn, ok := m.GetServer(serverName) + m.mu.RLock() + if m.closed { + m.mu.RUnlock() + return nil, fmt.Errorf("manager is closed") + } + conn, ok := m.servers[serverName] + m.mu.RUnlock() + if !ok { return nil, fmt.Errorf("server %s not found", serverName) } @@ -426,6 +434,11 @@ func (m *Manager) Close() error { m.mu.Lock() defer m.mu.Unlock() + if m.closed { + return nil + } + m.closed = true + logger.InfoCF("mcp", "Closing all MCP server connections", map[string]interface{}{ "count": len(m.servers), From a7a4e88fff1af11d8a675588ab7ab7cae830d222 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Thu, 19 Feb 2026 20:03:00 +0800 Subject: [PATCH 32/82] fix(agent): use fallback workspace path for MCP initialization Use cfg.WorkspacePath() as a fallback when defaultAgent is nil or its Workspace is empty. This ensures MCP servers with relative envFile paths can always resolve them correctly, even when agents haven't been fully initialized yet. Previously, workspacePath would be an empty string in these cases, causing relative envFile paths to fail to resolve. Now the fallback guarantees a valid workspace path is always provided to LoadFromMCPConfig. Addresses Copilot code review feedback. --- pkg/agent/loop.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 87b47f4ad..fc83007c2 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -140,8 +140,10 @@ func (al *AgentLoop) Run(ctx context.Context) error { mcpManager := mcp.NewManager() defaultAgent := al.registry.GetDefaultAgent() workspacePath := "" - if defaultAgent != nil { + if defaultAgent != nil && defaultAgent.Workspace != "" { workspacePath = defaultAgent.Workspace + } else { + workspacePath = al.cfg.WorkspacePath() } if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { From 26bca10b81cfb47ae4903bfad469a90e5591eb02 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Fri, 20 Feb 2026 20:31:44 +0200 Subject: [PATCH 33/82] feat(telegram): Do not fail on commands init --- pkg/channels/telegram.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 49c359ef5..63068dfc8 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -88,7 +88,9 @@ func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") if err := c.initBotCommands(ctx); err != nil { - return fmt.Errorf("failed to initialize bot commands: %w", err) + logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{ + "error": err.Error(), + }) } updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{ From e1ba69293e144d3fdb486516f54c193d09eb6fee Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Fri, 20 Feb 2026 20:34:09 +0200 Subject: [PATCH 34/82] feat(telegram): Updated log message --- pkg/channels/telegram.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 106988596..ca2d5322f 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -195,7 +195,7 @@ func (c *TelegramChannel) initBotCommands(ctx context.Context) error { return fmt.Errorf("set commands: %w", err) } } else { - logger.InfoC("telegram", "Bot commands up to date") + logger.DebugC("telegram", "Bot commands are up to date") } return nil From 2bf467fbbe7479d45501b5843508c8bb22da97d6 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Fri, 20 Feb 2026 22:14:02 +0200 Subject: [PATCH 35/82] feat(telegram): Fix conflicts --- pkg/channels/telegram.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 191a45a11..c1ade454c 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -14,7 +14,6 @@ import ( "github.com/mymmrac/telego" th "github.com/mymmrac/telego/telegohandler" - th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" @@ -140,7 +139,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error { go func() { if err = bh.Start(); err != nil { - logger.ErrorCF("telegram", "Bot handler failed", map[string]interface{}{ + logger.ErrorCF("telegram", "Bot handler failed", map[string]any{ "error": err.Error(), }) } From fb2b5940600c03349787a4147264f32133de5e92 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sat, 21 Feb 2026 13:39:42 +0800 Subject: [PATCH 36/82] fix(scripts): specify service name in docker compose build Avoid building zero services when all services are gated behind profiles. Without an explicit service target, 'docker compose build' silently skips all profile-gated services, causing subsequent 'docker compose run' to use stale or missing images. --- scripts/test-docker-mcp.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh index a0c9e232a..5c4e5cb56 100755 --- a/scripts/test-docker-mcp.sh +++ b/scripts/test-docker-mcp.sh @@ -11,7 +11,7 @@ echo "" # Build the image echo "📦 Building Docker image..." -docker compose -f "$COMPOSE_FILE" build +docker compose -f "$COMPOSE_FILE" build "$SERVICE" # Test npx echo "✅ Testing npx..." From 246fdf3f33d48b7361f9623f1566a020c894a32c Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sat, 21 Feb 2026 13:40:55 +0800 Subject: [PATCH 37/82] fix(mcp): guard against nil result from CallTool CallTool can return (nil, nil) if the underlying MCP library misbehaves. Without a nil check, result.IsError would panic. Return an explicit error ToolResult instead. --- pkg/tools/mcp_tool.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index c29ab8525..6bd3d75e3 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -119,6 +119,11 @@ func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) *Too return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) } + if result == nil { + nilErr := fmt.Errorf("MCP tool returned nil result without error") + return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr) + } + // Handle error result from server if result.IsError { errMsg := extractContentText(result.Content) From 59e9c5545413626d11e3628f981503ea16e8f82d Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sat, 21 Feb 2026 13:42:26 +0800 Subject: [PATCH 38/82] docs(config): restore MCP server examples in config.example.json Add back filesystem, github, brave-search, and postgres as example MCP server configurations. These were removed from DefaultConfig() to reduce memory footprint, but should remain in the example config as documentation for users setting up MCP servers. --- config/config.example.json | 45 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 5e49de2a7..2ad5664d6 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -220,7 +220,48 @@ }, "mcp": { "enabled": false, - "servers": {} + "servers": { + "filesystem": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + }, + "github": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "brave-search": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-brave-search" + ], + "env": { + "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY" + } + }, + "postgres": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + } + } }, "exec": { "enable_deny_patterns": false, @@ -250,4 +291,4 @@ "host": "0.0.0.0", "port": 18790 } -} +} \ No newline at end of file From 33058b534e50b6fe6507c1c2b5d53b1014893f09 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sat, 21 Feb 2026 13:45:00 +0800 Subject: [PATCH 39/82] fix(mcp): reject empty keys in loadEnvFile A line like '=value' would result in envVars[""] = "value", producing an invalid environment entry for the child process. Return an error instead when the key is empty. --- pkg/mcp/manager.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 1fc253774..bbc6925ea 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -73,6 +73,10 @@ func loadEnvFile(path string) (map[string]string, error) { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) + if key == "" { + return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum) + } + // Remove surrounding quotes if present if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || From d2b3fc1dd03abae11e1f2ab15258c8b31a5598a8 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sat, 21 Feb 2026 13:46:06 +0800 Subject: [PATCH 40/82] fix(mcp): include server name and cause in Close() errors Previously Close() discarded all underlying errors and returned only 'failed to close N server(s)', making debugging impossible. Now each error wraps the server name and original cause, and all errors are joined so callers can inspect the full failure list. --- pkg/mcp/manager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index bbc6925ea..8ff3af3dc 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -456,14 +456,14 @@ func (m *Manager) Close() error { "server": name, "error": err.Error(), }) - errs = append(errs, err) + errs = append(errs, fmt.Errorf("server %s: %w", name, err)) } } m.servers = make(map[string]*ServerConnection) if len(errs) > 0 { - return fmt.Errorf("failed to close %d server(s)", len(errs)) + return fmt.Errorf("failed to close %d server(s): %w", len(errs), errors.Join(errs...)) } return nil From 11dbc301f927ce7299df222996d5d83484647179 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sat, 21 Feb 2026 13:48:41 +0800 Subject: [PATCH 41/82] perf(agent): cache ListAgentIDs() result before MCP tool registration loop ListAgentIDs() was called on every iteration of the inner tool loop, causing repeated allocations. Capture the slice once and reuse it for both agentCount and the registration loop. --- pkg/agent/loop.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e3efea17b..eef800921 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -185,12 +185,13 @@ func (al *AgentLoop) Run(ctx context.Context) error { servers := mcpManager.GetServers() uniqueTools := 0 totalRegistrations := 0 - agentCount := len(al.registry.ListAgentIDs()) + agentIDs := al.registry.ListAgentIDs() + agentCount := len(agentIDs) for serverName, conn := range servers { uniqueTools += len(conn.Tools) for _, tool := range conn.Tools { - for _, agentID := range al.registry.ListAgentIDs() { + for _, agentID := range agentIDs { agent, ok := al.registry.GetAgent(agentID) if !ok { continue From cfc29a1383a3cdc26cdd2a74c162fc0cd7dec6c7 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sat, 21 Feb 2026 14:10:48 +0800 Subject: [PATCH 42/82] fix(mcp): prevent use-after-close race between CallTool and Close A race could occur when Close() called conn.Session.Close() concurrently with an in-flight conn.Session.CallTool(), leading to undefined behavior. Fix by adding a sync.WaitGroup to Manager: - CallTool increments the WaitGroup while holding the read lock (after checking m.closed), ensuring no new calls are counted after Close sets the flag - Close sets m.closed=true, releases the write lock, then waits for all in-flight calls to finish via wg.Wait() before closing sessions --- pkg/mcp/manager.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 8ff3af3dc..b28ba4670 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -108,6 +108,7 @@ type Manager struct { servers map[string]*ServerConnection mu sync.RWMutex closed bool + wg sync.WaitGroup // tracks in-flight CallTool calls } // NewManager creates a new MCP manager @@ -414,11 +415,15 @@ func (m *Manager) CallTool(ctx context.Context, serverName, toolName string, arg return nil, fmt.Errorf("manager is closed") } conn, ok := m.servers[serverName] + if ok { + m.wg.Add(1) + } m.mu.RUnlock() if !ok { return nil, fmt.Errorf("server %s not found", serverName) } + defer m.wg.Done() params := &mcp.CallToolParams{ Name: toolName, @@ -436,12 +441,18 @@ func (m *Manager) CallTool(ctx context.Context, serverName, toolName string, arg // Close closes all server connections func (m *Manager) Close() error { m.mu.Lock() - defer m.mu.Unlock() - if m.closed { + m.mu.Unlock() return nil } m.closed = true + m.mu.Unlock() + + // Wait for all in-flight CallTool calls to finish before closing sessions + m.wg.Wait() + + m.mu.Lock() + defer m.mu.Unlock() logger.InfoCF("mcp", "Closing all MCP server connections", map[string]interface{}{ From 6aade43236e9d5a90c11871dd4d34c0d65bc351f Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 22 Feb 2026 15:03:20 +0800 Subject: [PATCH 43/82] docs: add MCP tool configuration documentation --- docs/tools_configuration.md | 141 +++++++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 33 deletions(-) diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index 8aba1aa91..6204fb0c8 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -8,6 +8,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. { "tools": { "web": { ... }, + "mcp": { ... }, "exec": { ... }, "cron": { ... }, "skills": { ... } @@ -21,35 +22,35 @@ Web tools are used for web search and fetching. ### Brave -| Config | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | bool | false | Enable Brave search | -| `api_key` | string | - | Brave Search API key | -| `max_results` | int | 5 | Maximum number of results | +| Config | Type | Default | Description | +| ------------- | ------ | ------- | ------------------------- | +| `enabled` | bool | false | Enable Brave search | +| `api_key` | string | - | Brave Search API key | +| `max_results` | int | 5 | Maximum number of results | ### DuckDuckGo -| Config | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | bool | true | Enable DuckDuckGo search | -| `max_results` | int | 5 | Maximum number of results | +| Config | Type | Default | Description | +| ------------- | ---- | ------- | ------------------------- | +| `enabled` | bool | true | Enable DuckDuckGo search | +| `max_results` | int | 5 | Maximum number of results | ### Perplexity -| Config | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | bool | false | Enable Perplexity search | -| `api_key` | string | - | Perplexity API key | -| `max_results` | int | 5 | Maximum number of results | +| Config | Type | Default | Description | +| ------------- | ------ | ------- | ------------------------- | +| `enabled` | bool | false | Enable Perplexity search | +| `api_key` | string | - | Perplexity API key | +| `max_results` | int | 5 | Maximum number of results | ## Exec Tool The exec tool is used to execute shell commands. -| Config | Type | Default | Description | -|--------|------|---------|-------------| -| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | -| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | +| Config | Type | Default | Description | +| ---------------------- | ----- | ------- | ------------------------------------------ | +| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | +| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | ### Functionality @@ -80,10 +81,7 @@ By default, PicoClaw blocks the following dangerous commands: "tools": { "exec": { "enable_deny_patterns": true, - "custom_deny_patterns": [ - "\\brm\\s+-r\\b", - "\\bkillall\\s+python" - ] + "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"] } } } @@ -93,9 +91,84 @@ By default, PicoClaw blocks the following dangerous commands: The cron tool is used for scheduling periodic tasks. -| Config | Type | Default | Description | -|--------|------|---------|-------------| -| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | +| Config | Type | Default | Description | +| ---------------------- | ---- | ------- | ---------------------------------------------- | +| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | + +## MCP Tool + +The MCP tool enables integration with external Model Context Protocol servers. + +### Global Config + +| Config | Type | Default | Description | +| --------- | ------ | ------- | ----------------------------------- | +| `enabled` | bool | false | Enable MCP integration globally | +| `servers` | object | `{}` | Map of server name to server config | + +### Per-Server Config + +| Config | Type | Required | Description | +| ---------- | ------ | -------- | ------------------------------------------ | +| `enabled` | bool | yes | Enable this MCP server | +| `type` | string | no | Transport type: `stdio`, `sse`, `http` | +| `command` | string | stdio | Executable command for stdio transport | +| `args` | array | no | Command arguments for stdio transport | +| `env` | object | no | Environment variables for stdio process | +| `env_file` | string | no | Path to environment file for stdio process | +| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport | +| `headers` | object | no | HTTP headers for `sse`/`http` transport | + +### Transport Behavior + +- If `type` is omitted, transport is auto-detected: + - `url` is set → `sse` + - `command` is set → `stdio` +- `http` and `sse` both use `url` + optional `headers`. +- `env` and `env_file` are only applied to `stdio` servers. + +### Configuration Examples + +#### 1) Stdio MCP server + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +#### 2) Remote SSE/HTTP MCP server + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` ## Skills Tool @@ -103,13 +176,13 @@ The skills tool configures skill discovery and installation via registries like ### Registries -| Config | Type | Default | Description | -|--------|------|---------|-------------| -| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | -| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | -| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | -| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | -| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | +| Config | Type | Default | Description | +| ---------------------------------- | ------ | -------------------- | ----------------------- | +| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | ### Configuration Example @@ -136,8 +209,10 @@ The skills tool configures skill discovery and installation via registries like All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: For example: + - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` -Note: Array-type environment variables are not currently supported and must be set via the config file. +Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than environment variables. From 16a3b96ddebe4b1d75db05a19c6f36f814823118 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 22 Feb 2026 15:06:57 +0800 Subject: [PATCH 44/82] fix(mcp): validate workspace before resolving relative env_file --- pkg/mcp/manager.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index b28ba4670..90f31f0c1 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -161,6 +161,17 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig // Resolve relative envFile paths relative to workspace if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) { + if workspace == "" { + err := fmt.Errorf("workspace path is empty while resolving relative envFile %q for server %s", serverCfg.EnvFile, name) + logger.ErrorCF("mcp", "Invalid MCP server configuration", + map[string]interface{}{ + "server": name, + "env_file": serverCfg.EnvFile, + "error": err.Error(), + }) + errs <- err + return + } serverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile) } From 4e330b297c99a3f5c1e44869b84028b8069ca433 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 22 Feb 2026 15:13:29 +0800 Subject: [PATCH 45/82] test(mcp): add manager behavior and lifecycle unit tests --- pkg/mcp/manager_test.go | 112 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index 1c69e75f2..f9b8c07dd 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -1,9 +1,14 @@ package mcp import ( + "context" "os" "path/filepath" + "strings" "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sipeed/picoclaw/pkg/config" ) func TestLoadEnvFile(t *testing.T) { @@ -180,3 +185,110 @@ SHARED_VAR=from_file` t.Errorf("Expected NEW_VAR=from_config, got %s", merged["NEW_VAR"]) } } + +func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) { + mgr := NewManager() + + mcpCfg := config.MCPConfig{ + Enabled: true, + Servers: map[string]config.MCPServerConfig{ + "test-server": { + Enabled: true, + Command: "echo", + Args: []string{"ok"}, + EnvFile: ".env", + }, + }, + } + + err := mgr.LoadFromMCPConfig(context.Background(), mcpCfg, "") + if err == nil { + t.Fatal("expected error for relative env_file with empty workspace path, got nil") + } + + if !strings.Contains(err.Error(), "workspace path is empty") { + t.Fatalf("expected workspace path validation error, got: %v", err) + } +} + +func TestNewManager_InitialState(t *testing.T) { + mgr := NewManager() + if mgr == nil { + t.Fatal("expected manager instance, got nil") + } + if len(mgr.GetServers()) != 0 { + t.Fatalf("expected no servers on new manager, got %d", len(mgr.GetServers())) + } +} + +func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) { + mgr := NewManager() + + err := mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: false}, "/tmp") + if err != nil { + t.Fatalf("expected nil error when MCP disabled, got: %v", err) + } + + err = mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: true}, "/tmp") + if err != nil { + t.Fatalf("expected nil error when no servers configured, got: %v", err) + } +} + +func TestGetServers_ReturnsCopy(t *testing.T) { + mgr := NewManager() + mgr.servers["s1"] = &ServerConnection{Name: "s1"} + + servers := mgr.GetServers() + delete(servers, "s1") + + if _, ok := mgr.GetServer("s1"); !ok { + t.Fatal("expected internal manager state to remain unchanged") + } +} + +func TestGetAllTools_FiltersEmptyTools(t *testing.T) { + mgr := NewManager() + mgr.servers["empty"] = &ServerConnection{Name: "empty", Tools: nil} + mgr.servers["with-tools"] = &ServerConnection{Name: "with-tools", Tools: []*sdkmcp.Tool{{}}} + + all := mgr.GetAllTools() + if _, ok := all["empty"]; ok { + t.Fatal("expected server without tools to be excluded") + } + if _, ok := all["with-tools"]; !ok { + t.Fatal("expected server with tools to be included") + } +} + +func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) { + t.Run("manager closed", func(t *testing.T) { + mgr := NewManager() + mgr.closed = true + + _, err := mgr.CallTool(context.Background(), "s1", "tool", nil) + if err == nil || !strings.Contains(err.Error(), "manager is closed") { + t.Fatalf("expected manager closed error, got: %v", err) + } + }) + + t.Run("server missing", func(t *testing.T) { + mgr := NewManager() + + _, err := mgr.CallTool(context.Background(), "missing", "tool", nil) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected server not found error, got: %v", err) + } + }) +} + +func TestClose_IdempotentOnEmptyManager(t *testing.T) { + mgr := NewManager() + + if err := mgr.Close(); err != nil { + t.Fatalf("first close should succeed, got: %v", err) + } + if err := mgr.Close(); err != nil { + t.Fatalf("second close should be idempotent, got: %v", err) + } +} From 89bc7aaea51ff2bfa1d1a14a2781e2d9906d1467 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Mon, 23 Feb 2026 15:45:04 +0300 Subject: [PATCH 46/82] fix: add generate dependency to test and vet Makefile targets make test and make vet fail on a fresh clone because the go:embed workspace directory does not exist until go generate runs. The build target already depends on generate, but test and vet did not. Also fixes the test target comment which incorrectly read '## fmt: Format Go code'. --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 29e2fc964..6f11388f0 100644 --- a/Makefile +++ b/Makefile @@ -129,11 +129,11 @@ clean: @echo "Clean complete" ## vet: Run go vet for static analysis -vet: +vet: generate @$(GO) vet ./... ## test: Test Go code -test: +test: generate @$(GO) test ./... ## fmt: Format Go code From b0c8fc4a7ed21657a56d487ac8636e6cdd2678eb Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Sat, 28 Feb 2026 23:32:15 +0200 Subject: [PATCH 47/82] feat(telegram): Fix conflicts --- pkg/channels/telegram.go | 585 ------------------------------ pkg/channels/telegram/telegram.go | 74 +++- 2 files changed, 65 insertions(+), 594 deletions(-) delete mode 100644 pkg/channels/telegram.go diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go deleted file mode 100644 index c1ade454c..000000000 --- a/pkg/channels/telegram.go +++ /dev/null @@ -1,585 +0,0 @@ -package channels - -import ( - "context" - "fmt" - "net/http" - "net/url" - "os" - "regexp" - "slices" - "strings" - "sync" - "time" - - "github.com/mymmrac/telego" - th "github.com/mymmrac/telego/telegohandler" - tu "github.com/mymmrac/telego/telegoutil" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" -) - -type TelegramChannel struct { - *BaseChannel - bot *telego.Bot - botHandler *th.BotHandler - commands TelegramCommander - config *config.Config - chatIDs map[string]int64 - transcriber *voice.GroqTranscriber - placeholders sync.Map // chatID -> messageID - stopThinking sync.Map // chatID -> thinkingCancel -} - -type thinkingCancel struct { - fn context.CancelFunc -} - -func (c *thinkingCancel) Cancel() { - if c != nil && c.fn != nil { - c.fn() - } -} - -func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { - var opts []telego.BotOption - telegramCfg := cfg.Channels.Telegram - - if telegramCfg.Proxy != "" { - proxyURL, parseErr := url.Parse(telegramCfg.Proxy) - if parseErr != nil { - return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr) - } - opts = append(opts, telego.WithHTTPClient(&http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - }, - })) - } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" { - // Use environment proxy if configured - opts = append(opts, telego.WithHTTPClient(&http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - }, - })) - } - - bot, err := telego.NewBot(telegramCfg.Token, opts...) - if err != nil { - return nil, fmt.Errorf("failed to create telegram bot: %w", err) - } - - base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom) - - return &TelegramChannel{ - BaseChannel: base, - commands: NewTelegramCommands(bot, cfg), - bot: bot, - config: cfg, - chatIDs: make(map[string]int64), - transcriber: nil, - placeholders: sync.Map{}, - stopThinking: sync.Map{}, - }, nil -} - -func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - -func (c *TelegramChannel) Start(ctx context.Context) error { - logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") - - if err := c.initBotCommands(ctx); err != nil { - logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{ - "error": err.Error(), - }) - } - - updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{ - Timeout: 30, - }) - if err != nil { - return fmt.Errorf("failed to start long polling: %w", err) - } - - bh, err := th.NewBotHandler(c.bot, updates) - if err != nil { - return fmt.Errorf("failed to create bot handler: %w", err) - } - c.botHandler = bh - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.Start(ctx, message) - }, th.CommandEqual("start")) - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.Help(ctx, message) - }, th.CommandEqual("help")) - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.Show(ctx, message) - }, th.CommandEqual("show")) - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.List(ctx, message) - }, th.CommandEqual("list")) - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.handleMessage(ctx, &message) - }, th.AnyMessage()) - - c.setRunning(true) - logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ - "username": c.bot.Username(), - }) - - go func() { - if err = bh.Start(); err != nil { - logger.ErrorCF("telegram", "Bot handler failed", map[string]any{ - "error": err.Error(), - }) - } - }() - - return nil -} - -func (c *TelegramChannel) Stop(ctx context.Context) error { - logger.InfoC("telegram", "Stopping Telegram bot...") - c.setRunning(false) - if c.botHandler != nil { - _ = c.botHandler.StopWithContext(ctx) - } - return nil -} - -func (c *TelegramChannel) initBotCommands(ctx context.Context) error { - currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{ - Scope: tu.ScopeDefault(), - }) - if err != nil { - return fmt.Errorf("get commands: %w", err) - } - - commands := []telego.BotCommand{ - { - Command: "start", - Description: "Start the bot", - }, - { - Command: "help", - Description: "Show a help message", - }, - { - Command: "show", - Description: "Show current configuration", - }, - { - Command: "list", - Description: "List available options", - }, - } - - // Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed - if !slices.Equal(currentCommands, commands) { - logger.InfoC("telegram", "Updating bot commands") - - err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{ - Commands: commands, - Scope: tu.ScopeDefault(), - }) - if err != nil { - return fmt.Errorf("set commands: %w", err) - } - } else { - logger.DebugC("telegram", "Bot commands are up to date") - } - - return nil -} - -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("telegram bot not running") - } - - chatID, err := parseChatID(msg.ChatID) - if err != nil { - return fmt.Errorf("invalid chat ID: %w", err) - } - - // Stop thinking animation - if stop, ok := c.stopThinking.Load(msg.ChatID); ok { - if cf, ok := stop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - c.stopThinking.Delete(msg.ChatID) - } - - htmlContent := markdownToTelegramHTML(msg.Content) - - // Try to edit placeholder - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - return nil - } - // Fallback to new message if edit fails - } - - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ - "error": err.Error(), - }) - tgMsg.ParseMode = "" - _, err = c.bot.SendMessage(ctx, tgMsg) - return err - } - - return nil -} - -func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { - if message == nil { - return fmt.Errorf("message is nil") - } - - user := message.From - if user == nil { - return fmt.Errorf("message sender (user) is nil") - } - - senderID := fmt.Sprintf("%d", user.ID) - if user.Username != "" { - senderID = fmt.Sprintf("%d|%s", user.ID, user.Username) - } - - // 检查白名单,避免为被拒绝的用户下载附件 - if !c.IsAllowed(senderID) { - logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ - "user_id": senderID, - }) - return nil - } - - chatID := message.Chat.ID - c.chatIDs[senderID] = chatID - - content := "" - mediaPaths := []string{} - localFiles := []string{} // 跟踪需要清理的本地文件 - - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() - - if message.Text != "" { - content += message.Text - } - - if message.Caption != "" { - if content != "" { - content += "\n" - } - content += message.Caption - } - - if len(message.Photo) > 0 { - photo := message.Photo[len(message.Photo)-1] - photoPath := c.downloadPhoto(ctx, photo.FileID) - if photoPath != "" { - localFiles = append(localFiles, photoPath) - mediaPaths = append(mediaPaths, photoPath) - if content != "" { - content += "\n" - } - content += "[image: photo]" - } - } - - if message.Voice != nil { - voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") - if voicePath != "" { - localFiles = append(localFiles, voicePath) - mediaPaths = append(mediaPaths, voicePath) - - transcribedText := "" - if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - result, err := c.transcriber.Transcribe(ctx, voicePath) - if err != nil { - logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ - "error": err.Error(), - "path": voicePath, - }) - transcribedText = "[voice (transcription failed)]" - } else { - transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = "[voice]" - } - - if content != "" { - content += "\n" - } - content += transcribedText - } - } - - if message.Audio != nil { - audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") - if audioPath != "" { - localFiles = append(localFiles, audioPath) - mediaPaths = append(mediaPaths, audioPath) - if content != "" { - content += "\n" - } - content += "[audio]" - } - } - - if message.Document != nil { - docPath := c.downloadFile(ctx, message.Document.FileID, "") - if docPath != "" { - localFiles = append(localFiles, docPath) - mediaPaths = append(mediaPaths, docPath) - if content != "" { - content += "\n" - } - content += "[file]" - } - } - - if content == "" { - content = "[empty message]" - } - - logger.DebugCF("telegram", "Received message", map[string]any{ - "sender_id": senderID, - "chat_id": fmt.Sprintf("%d", chatID), - "preview": utils.Truncate(content, 50), - }) - - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) - if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{ - "error": err.Error(), - }) - } - - // Stop any previous thinking animation - chatIDStr := fmt.Sprintf("%d", chatID) - if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { - if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - } - - // Create cancel function for thinking state - _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) - c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) - if err == nil { - pID := pMsg.MessageID - c.placeholders.Store(chatIDStr, pID) - } - - peerKind := "direct" - peerID := fmt.Sprintf("%d", user.ID) - if message.Chat.Type != "private" { - peerKind = "group" - peerID = fmt.Sprintf("%d", chatID) - } - - metadata := map[string]string{ - "message_id": fmt.Sprintf("%d", message.MessageID), - "user_id": fmt.Sprintf("%d", user.ID), - "username": user.Username, - "first_name": user.FirstName, - "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), - "peer_kind": peerKind, - "peer_id": peerID, - } - - c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) - return nil -} - -func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { - file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) - if err != nil { - logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{ - "error": err.Error(), - }) - return "" - } - - return c.downloadFileWithInfo(file, ".jpg") -} - -func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string { - if file.FilePath == "" { - return "" - } - - url := c.bot.FileDownloadURL(file.FilePath) - logger.DebugCF("telegram", "File URL", map[string]any{"url": url}) - - // Use FilePath as filename for better identification - filename := file.FilePath + ext - return utils.DownloadFile(url, filename, utils.DownloadOptions{ - LoggerPrefix: "telegram", - }) -} - -func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { - file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) - if err != nil { - logger.ErrorCF("telegram", "Failed to get file", map[string]any{ - "error": err.Error(), - }) - return "" - } - - return c.downloadFileWithInfo(file, ext) -} - -func parseChatID(chatIDStr string) (int64, error) { - var id int64 - _, err := fmt.Sscanf(chatIDStr, "%d", &id) - return id, err -} - -func markdownToTelegramHTML(text string) string { - if text == "" { - return "" - } - - codeBlocks := extractCodeBlocks(text) - text = codeBlocks.text - - inlineCodes := extractInlineCodes(text) - text = inlineCodes.text - - text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") - - text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1") - - text = escapeHTML(text) - - text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`) - - text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1") - - text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1") - - reItalic := regexp.MustCompile(`_([^_]+)_`) - text = reItalic.ReplaceAllStringFunc(text, func(s string) string { - match := reItalic.FindStringSubmatch(s) - if len(match) < 2 { - return s - } - return "" + match[1] + "" - }) - - text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1") - - text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ") - - for i, code := range inlineCodes.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) - } - - for i, code := range codeBlocks.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll( - text, - fmt.Sprintf("\x00CB%d\x00", i), - fmt.Sprintf("
%s
", escaped), - ) - } - - return text -} - -type codeBlockMatch struct { - text string - codes []string -} - -func extractCodeBlocks(text string) codeBlockMatch { - re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") - matches := re.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = re.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00CB%d\x00", i) - i++ - return placeholder - }) - - return codeBlockMatch{text: text, codes: codes} -} - -type inlineCodeMatch struct { - text string - codes []string -} - -func extractInlineCodes(text string) inlineCodeMatch { - re := regexp.MustCompile("`([^`]+)`") - matches := re.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = re.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00IC%d\x00", i) - i++ - return placeholder - }) - - return inlineCodeMatch{text: text, codes: codes} -} - -func escapeHTML(text string) string { - text = strings.ReplaceAll(text, "&", "&") - text = strings.ReplaceAll(text, "<", "<") - text = strings.ReplaceAll(text, ">", ">") - return text -} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a11cf53b8..7feb706aa 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -7,12 +7,12 @@ import ( "net/url" "os" "regexp" + "slices" "strconv" "strings" "time" "github.com/mymmrac/telego" - "github.com/mymmrac/telego/telegohandler" th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" @@ -41,7 +41,7 @@ var ( type TelegramChannel struct { *channels.BaseChannel bot *telego.Bot - bh *telegohandler.BotHandler + bh *th.BotHandler commands TelegramCommander config *config.Config chatIDs map[string]int64 @@ -101,6 +101,12 @@ func (c *TelegramChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) + if err := c.initBotCommands(c.ctx); err != nil { + logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{ + "error": err.Error(), + }) + } + updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ Timeout: 30, }) @@ -109,20 +115,19 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to start long polling: %w", err) } - bh, err := telegohandler.NewBotHandler(c.bot, updates) + bh, err := th.NewBotHandler(c.bot, updates) if err != nil { c.cancel() return fmt.Errorf("failed to create bot handler: %w", err) } c.bh = bh - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - c.commands.Help(ctx, message) - return nil - }, th.CommandEqual("help")) bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.commands.Start(ctx, message) }, th.CommandEqual("start")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Help(ctx, message) + }, th.CommandEqual("help")) bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.commands.Show(ctx, message) @@ -141,7 +146,13 @@ func (c *TelegramChannel) Start(ctx context.Context) error { "username": c.bot.Username(), }) - go bh.Start() + go func() { + if err = bh.Start(); err != nil { + logger.ErrorCF("telegram", "Bot handler failed", map[string]any{ + "error": err.Error(), + }) + } + }() return nil } @@ -152,7 +163,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { // Stop the bot handler if c.bh != nil { - c.bh.Stop() + _ = c.bh.StopWithContext(ctx) } // Cancel our context (stops long polling) @@ -163,6 +174,51 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { return nil } +func (c *TelegramChannel) initBotCommands(ctx context.Context) error { + currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{ + Scope: tu.ScopeDefault(), + }) + if err != nil { + return fmt.Errorf("get commands: %w", err) + } + + commands := []telego.BotCommand{ + { + Command: "start", + Description: "Start the bot", + }, + { + Command: "help", + Description: "Show a help message", + }, + { + Command: "show", + Description: "Show current configuration", + }, + { + Command: "list", + Description: "List available options", + }, + } + + // Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed + if !slices.Equal(currentCommands, commands) { + logger.InfoC("telegram", "Updating bot commands") + + err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{ + Commands: commands, + Scope: tu.ScopeDefault(), + }) + if err != nil { + return fmt.Errorf("set commands: %w", err) + } + } else { + logger.DebugC("telegram", "Bot commands are up to date") + } + + return nil +} + func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning From 077d7c8d9b04d84c7c8072f3f07bc7a6f5e24a50 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 1 Mar 2026 08:53:13 +0800 Subject: [PATCH 48/82] chore: fix lint issues in mcp and agent packages - Fix gci import ordering in manager.go, manager_test.go - Fix gofmt formatting in loop.go, manager.go, mcp_tool.go, mcp_tool_test.go - Fix gofumpt formatting in manager_test.go - Fix golines line length issues in manager.go, mcp_tool_test.go - Fix wastedassign: replace redundant zero-value init with var declaration in loop.go --- pkg/agent/loop.go | 124 +++++++++++++++++++++++++++---------- pkg/mcp/manager.go | 64 ++++++++++++------- pkg/mcp/manager_test.go | 10 ++- pkg/tools/mcp_tool.go | 32 +++++----- pkg/tools/mcp_tool_test.go | 50 ++++++++------- 5 files changed, 186 insertions(+), 94 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 12ca35148..338ce9c58 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -58,7 +58,11 @@ type processOptions struct { const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." -func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { +func NewAgentLoop( + cfg *config.Config, + msgBus *bus.MessageBus, + provider providers.LLMProvider, +) *AgentLoop { registry := NewAgentRegistry(cfg, provider) // Register shared tools to all agents @@ -166,7 +170,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { if al.cfg.Tools.MCP.Enabled { mcpManager := mcp.NewManager() defaultAgent := al.registry.GetDefaultAgent() - workspacePath := "" + var workspacePath string if defaultAgent != nil && defaultAgent.Workspace != "" { workspacePath = defaultAgent.Workspace } else { @@ -175,7 +179,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", - map[string]interface{}{ + map[string]any{ "error": err.Error(), }) } else { @@ -183,7 +187,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { defer func() { if err := mcpManager.Close(); err != nil { logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]interface{}{ + map[string]any{ "error": err.Error(), }) } @@ -208,7 +212,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { agent.Tools.Register(mcpTool) totalRegistrations++ logger.DebugCF("agent", "Registered MCP tool", - map[string]interface{}{ + map[string]any{ "agent_id": agentID, "server": serverName, "tool": tool.Name, @@ -218,7 +222,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } } logger.InfoCF("agent", "MCP tools registered successfully", - map[string]interface{}{ + map[string]any{ "server_count": len(servers), "unique_tools": uniqueTools, "total_registrations": totalRegistrations, @@ -367,7 +371,10 @@ func (al *AgentLoop) RecordLastChatID(chatID string) error { return al.state.SetLastChatID(chatID) } -func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { +func (al *AgentLoop) ProcessDirect( + ctx context.Context, + content, sessionKey string, +) (string, error) { return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") } @@ -388,7 +395,10 @@ func (al *AgentLoop) ProcessDirectWithChannel( // ProcessHeartbeat processes a heartbeat request without session history. // Each heartbeat is independent and doesn't accumulate context. -func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { +func (al *AgentLoop) ProcessHeartbeat( + ctx context.Context, + content, channel, chatID string, +) (string, error) { agent := al.registry.GetDefaultAgent() if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") @@ -413,13 +423,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } else { logContent = utils.Truncate(msg.Content, 80) } - logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), + logger.InfoCF( + "agent", + fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), map[string]any{ "channel": msg.Channel, "chat_id": msg.ChatID, "sender_id": msg.SenderID, "session_key": msg.SessionKey, - }) + }, + ) // Route system messages to processSystemMessage if msg.Channel == "system" { @@ -480,9 +493,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }) } -func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { +func (al *AgentLoop) processSystemMessage( + ctx context.Context, + msg bus.InboundMessage, +) (string, error) { if msg.Channel != "system" { - return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel) + return "", fmt.Errorf( + "processSystemMessage called with non-system message channel: %s", + msg.Channel, + ) } logger.InfoCF("agent", "Processing system message", @@ -540,14 +559,22 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe } // runAgentLoop is the core message processing logic. -func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) { +func (al *AgentLoop) runAgentLoop( + ctx context.Context, + agent *AgentInstance, + opts processOptions, +) (string, error) { // 0. Record last channel for heartbeat notifications (skip internal channels) if opts.Channel != "" && opts.ChatID != "" { // Don't record internal channels (cli, system, subagent) if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) + logger.WarnCF( + "agent", + "Failed to record last channel", + map[string]any{"error": err.Error()}, + ) } } } @@ -629,7 +656,10 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string return "" } -func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) { +func (al *AgentLoop) handleReasoning( + ctx context.Context, + reasoningContent, channelName, channelID string, +) { if reasoningContent == "" || channelName == "" || channelID == "" { return } @@ -697,22 +727,33 @@ func (al *AgentLoop) runLLMIteration( callLLM := func() (*providers.LLMResponse, error) { if len(agent.Candidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, + fbResult, fbErr := al.fallback.Execute( + ctx, + agent.Candidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, - "prompt_cache_key": agent.ID, - }) + return agent.Provider.Chat( + ctx, + messages, + providerToolDefs, + model, + map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, + }, + ) }, ) if fbErr != nil { return nil, fbErr } if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { - logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", - fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]any{"agent_id": agent.ID, "iteration": iteration}) + logger.InfoCF( + "agent", + fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": agent.ID, "iteration": iteration}, + ) } return fbResult.Response, nil } @@ -738,10 +779,14 @@ func (al *AgentLoop) runLLMIteration( strings.Contains(errMsg, "length") if isContextError && retry < maxRetries { - logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ - "error": err.Error(), - "retry": retry, - }) + logger.WarnCF( + "agent", + "Context window error detected, attempting compression", + map[string]any{ + "error": err.Error(), + "retry": retry, + }, + ) if retry == 0 && !constants.IsInternalChannel(opts.Channel) { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ @@ -773,7 +818,12 @@ func (al *AgentLoop) runLLMIteration( return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } - go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) + go al.handleReasoning( + ctx, + response.Reasoning, + opts.Channel, + al.targetReasoningChannelID(opts.Channel), + ) logger.DebugCF("agent", "LLM response", map[string]any{ @@ -1075,7 +1125,11 @@ func formatMessagesForLog(messages []providers.Message) string { for _, tc := range msg.ToolCalls { fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) if tc.Function != nil { - fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) + fmt.Fprintf( + &sb, + " Arguments: %s\n", + utils.Truncate(tc.Function.Arguments, 200), + ) } } } @@ -1104,7 +1158,11 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) if len(tool.Function.Parameters) > 0 { - fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) + fmt.Fprintf( + &sb, + " Parameters: %s\n", + utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), + ) } } sb.WriteString("]") @@ -1201,7 +1259,9 @@ func (al *AgentLoop) summarizeBatch( existingSummary string, ) (string, error) { var sb strings.Builder - sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") + sb.WriteString( + "Provide a concise summary of this conversation segment, preserving core context and key points.\n", + ) if existingSummary != "" { sb.WriteString("Existing context: ") sb.WriteString(existingSummary) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 90f31f0c1..e79c9d3f8 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -13,6 +13,7 @@ import ( "sync" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -125,7 +126,11 @@ func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error // LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path. // This is the minimal dependency version that doesn't require the full Config object. -func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig, workspacePath string) error { +func (m *Manager) LoadFromMCPConfig( + ctx context.Context, + mcpCfg config.MCPConfig, + workspacePath string, +) error { if !mcpCfg.Enabled { logger.InfoCF("mcp", "MCP integration is disabled", nil) return nil @@ -137,7 +142,7 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig } logger.InfoCF("mcp", "Initializing MCP servers", - map[string]interface{}{ + map[string]any{ "count": len(mcpCfg.Servers), }) @@ -148,7 +153,7 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig for name, serverCfg := range mcpCfg.Servers { if !serverCfg.Enabled { logger.DebugCF("mcp", "Skipping disabled server", - map[string]interface{}{ + map[string]any{ "server": name, }) continue @@ -162,9 +167,13 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig // Resolve relative envFile paths relative to workspace if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) { if workspace == "" { - err := fmt.Errorf("workspace path is empty while resolving relative envFile %q for server %s", serverCfg.EnvFile, name) + err := fmt.Errorf( + "workspace path is empty while resolving relative envFile %q for server %s", + serverCfg.EnvFile, + name, + ) logger.ErrorCF("mcp", "Invalid MCP server configuration", - map[string]interface{}{ + map[string]any{ "server": name, "env_file": serverCfg.EnvFile, "error": err.Error(), @@ -177,7 +186,7 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig if err := m.ConnectServer(ctx, name, serverCfg); err != nil { logger.ErrorCF("mcp", "Failed to connect to MCP server", - map[string]interface{}{ + map[string]any{ "server": name, "error": err.Error(), }) @@ -200,7 +209,7 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig // If all enabled servers failed to connect, return aggregated error if enabledCount > 0 && connectedCount == 0 { logger.ErrorCF("mcp", "All MCP servers failed to connect", - map[string]interface{}{ + map[string]any{ "failed": len(allErrors), "total": enabledCount, }) @@ -209,7 +218,7 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig if len(allErrors) > 0 { logger.WarnCF("mcp", "Some MCP servers failed to connect", - map[string]interface{}{ + map[string]any{ "failed": len(allErrors), "connected": connectedCount, "total": enabledCount, @@ -218,7 +227,7 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig } logger.InfoCF("mcp", "MCP server initialization complete", - map[string]interface{}{ + map[string]any{ "connected": connectedCount, "total": enabledCount, }) @@ -227,9 +236,13 @@ func (m *Manager) LoadFromMCPConfig(ctx context.Context, mcpCfg config.MCPConfig } // ConnectServer connects to a single MCP server -func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCPServerConfig) error { +func (m *Manager) ConnectServer( + ctx context.Context, + name string, + cfg config.MCPServerConfig, +) error { logger.InfoCF("mcp", "Connecting to MCP server", - map[string]interface{}{ + map[string]any{ "server": name, "command": cfg.Command, "args": cfg.Args, @@ -263,7 +276,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP return fmt.Errorf("URL is required for SSE/HTTP transport") } logger.DebugCF("mcp", "Using SSE/HTTP transport", - map[string]interface{}{ + map[string]any{ "server": name, "url": cfg.URL, }) @@ -282,7 +295,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP }, } logger.DebugCF("mcp", "Added custom HTTP headers", - map[string]interface{}{ + map[string]any{ "server": name, "header_count": len(cfg.Headers), }) @@ -294,7 +307,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP return fmt.Errorf("command is required for stdio transport") } logger.DebugCF("mcp", "Using stdio transport", - map[string]interface{}{ + map[string]any{ "server": name, "command": cfg.Command, }) @@ -322,7 +335,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP envMap[k] = v } logger.DebugCF("mcp", "Loaded environment variables from file", - map[string]interface{}{ + map[string]any{ "server": name, "envFile": cfg.EnvFile, "var_count": len(envVars), @@ -343,7 +356,10 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP transport = &mcp.CommandTransport{Command: cmd} default: - return fmt.Errorf("unsupported transport type: %s (supported: stdio, sse, http)", transportType) + return fmt.Errorf( + "unsupported transport type: %s (supported: stdio, sse, http)", + transportType, + ) } // Connect to server @@ -355,7 +371,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP // Get server info initResult := session.InitializeResult() logger.InfoCF("mcp", "Connected to MCP server", - map[string]interface{}{ + map[string]any{ "server": name, "serverName": initResult.ServerInfo.Name, "serverVersion": initResult.ServerInfo.Version, @@ -368,7 +384,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP for tool, err := range session.Tools(ctx, nil) { if err != nil { logger.WarnCF("mcp", "Error listing tool", - map[string]interface{}{ + map[string]any{ "server": name, "error": err.Error(), }) @@ -378,7 +394,7 @@ func (m *Manager) ConnectServer(ctx context.Context, name string, cfg config.MCP } logger.InfoCF("mcp", "Listed tools from MCP server", - map[string]interface{}{ + map[string]any{ "server": name, "toolCount": len(tools), }) @@ -419,7 +435,11 @@ func (m *Manager) GetServer(name string) (*ServerConnection, bool) { } // CallTool calls a tool on a specific server -func (m *Manager) CallTool(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { +func (m *Manager) CallTool( + ctx context.Context, + serverName, toolName string, + arguments map[string]any, +) (*mcp.CallToolResult, error) { m.mu.RLock() if m.closed { m.mu.RUnlock() @@ -466,7 +486,7 @@ func (m *Manager) Close() error { defer m.mu.Unlock() logger.InfoCF("mcp", "Closing all MCP server connections", - map[string]interface{}{ + map[string]any{ "count": len(m.servers), }) @@ -474,7 +494,7 @@ func (m *Manager) Close() error { for name, conn := range m.servers { if err := conn.Session.Close(); err != nil { logger.ErrorCF("mcp", "Failed to close server connection", - map[string]interface{}{ + map[string]any{ "server": name, "error": err.Error(), }) diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index f9b8c07dd..6dd71a3c2 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -8,6 +8,7 @@ import ( "testing" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sipeed/picoclaw/pkg/config" ) @@ -95,7 +96,7 @@ PORT =8080`, tmpDir := t.TempDir() envFile := filepath.Join(tmpDir, ".env") - if err := os.WriteFile(envFile, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(envFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -144,7 +145,7 @@ func TestEnvFilePriority(t *testing.T) { DATABASE_URL=from_file SHARED_VAR=from_file` - if err := os.WriteFile(envFile, []byte(envContent), 0644); err != nil { + if err := os.WriteFile(envFile, []byte(envContent), 0o644); err != nil { t.Fatalf("Failed to create .env file: %v", err) } @@ -176,7 +177,10 @@ SHARED_VAR=from_file` // Verify priority: config.Env should override envFile if merged["SHARED_VAR"] != "from_config" { - t.Errorf("Expected SHARED_VAR=from_config (config should override file), got %s", merged["SHARED_VAR"]) + t.Errorf( + "Expected SHARED_VAR=from_config (config should override file), got %s", + merged["SHARED_VAR"], + ) } if merged["API_KEY"] != "from_file" { t.Errorf("Expected API_KEY=from_file, got %s", merged["API_KEY"]) diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index 6bd3d75e3..69615b4bc 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -12,7 +12,11 @@ import ( // MCPManager defines the interface for MCP manager operations // This allows for easier testing with mock implementations type MCPManager interface { - CallTool(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) + CallTool( + ctx context.Context, + serverName, toolName string, + arguments map[string]any, + ) (*mcp.CallToolResult, error) } // MCPTool wraps an MCP tool to implement the Tool interface @@ -48,21 +52,21 @@ func (t *MCPTool) Description() string { } // Parameters returns the tool parameters schema -func (t *MCPTool) Parameters() map[string]interface{} { +func (t *MCPTool) Parameters() map[string]any { // The InputSchema is already a JSON Schema object schema := t.tool.InputSchema // Handle nil schema if schema == nil { - return map[string]interface{}{ + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, "required": []string{}, } } // Try direct conversion first (fast path) - if schemaMap, ok := schema.(map[string]interface{}); ok { + if schemaMap, ok := schema.(map[string]any); ok { return schemaMap } @@ -75,14 +79,14 @@ func (t *MCPTool) Parameters() map[string]interface{} { } if jsonData != nil { - var result map[string]interface{} + var result map[string]any if err := json.Unmarshal(jsonData, &result); err == nil { return result } // Fallback on error - return map[string]interface{}{ + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, "required": []string{}, } } @@ -92,19 +96,19 @@ func (t *MCPTool) Parameters() map[string]interface{} { jsonData, err = json.Marshal(schema) if err != nil { // Fallback to empty schema if marshaling fails - return map[string]interface{}{ + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, "required": []string{}, } } - var result map[string]interface{} + var result map[string]any if err := json.Unmarshal(jsonData, &result); err != nil { // Fallback to empty schema if unmarshaling fails - return map[string]interface{}{ + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, "required": []string{}, } } @@ -113,7 +117,7 @@ func (t *MCPTool) Parameters() map[string]interface{} { } // Execute executes the MCP tool -func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult { result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args) if err != nil { return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index 580abc2ba..95bb0f992 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -11,10 +11,14 @@ import ( // MockMCPManager is a mock implementation of MCPManager interface for testing type MockMCPManager struct { - callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) + callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) } -func (m *MockMCPManager) CallTool(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { +func (m *MockMCPManager) CallTool( + ctx context.Context, + serverName, toolName string, + arguments map[string]any, +) (*mcp.CallToolResult, error) { if m.callToolFunc != nil { return m.callToolFunc(ctx, serverName, toolName, arguments) } @@ -32,10 +36,10 @@ func TestNewMCPTool(t *testing.T) { tool := &mcp.Tool{ Name: "test_tool", Description: "A test tool", - InputSchema: map[string]interface{}{ + InputSchema: map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "input": map[string]interface{}{ + "properties": map[string]any{ + "input": map[string]any{ "type": "string", "description": "Test input", }, @@ -142,17 +146,17 @@ func TestMCPTool_Description(t *testing.T) { func TestMCPTool_Parameters(t *testing.T) { tests := []struct { name string - inputSchema interface{} + inputSchema any expectType string checkProperty string expectProperty bool }{ { name: "map schema", - inputSchema: map[string]interface{}{ + inputSchema: map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ + "properties": map[string]any{ + "query": map[string]any{ "type": "string", "description": "Search query", }, @@ -212,7 +216,7 @@ func TestMCPTool_Parameters(t *testing.T) { // Check if property exists when expected if tt.checkProperty != "" { - properties, ok := params["properties"].(map[string]interface{}) + properties, ok := params["properties"].(map[string]any) if !ok && tt.expectProperty { t.Errorf("Expected properties to be a map") return @@ -232,7 +236,7 @@ func TestMCPTool_Parameters(t *testing.T) { // TestMCPTool_Execute_Success tests successful tool execution func TestMCPTool_Execute_Success(t *testing.T) { manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { // Verify correct parameters passed if serverName != "github" { t.Errorf("Expected serverName 'github', got '%s'", serverName) @@ -257,7 +261,7 @@ func TestMCPTool_Execute_Success(t *testing.T) { mcpTool := NewMCPTool(manager, "github", tool) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "query": "golang mcp", } @@ -277,7 +281,7 @@ func TestMCPTool_Execute_Success(t *testing.T) { // TestMCPTool_Execute_ManagerError tests execution when manager returns error func TestMCPTool_Execute_ManagerError(t *testing.T) { manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { return nil, fmt.Errorf("connection failed") }, } @@ -286,7 +290,7 @@ func TestMCPTool_Execute_ManagerError(t *testing.T) { mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() - result := mcpTool.Execute(ctx, map[string]interface{}{}) + result := mcpTool.Execute(ctx, map[string]any{}) if result == nil { t.Fatal("Result should not be nil") @@ -305,7 +309,7 @@ func TestMCPTool_Execute_ManagerError(t *testing.T) { // TestMCPTool_Execute_ServerError tests execution when server returns error func TestMCPTool_Execute_ServerError(t *testing.T) { manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: "Invalid API key"}, @@ -319,7 +323,7 @@ func TestMCPTool_Execute_ServerError(t *testing.T) { mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() - result := mcpTool.Execute(ctx, map[string]interface{}{}) + result := mcpTool.Execute(ctx, map[string]any{}) if result == nil { t.Fatal("Result should not be nil") @@ -338,7 +342,7 @@ func TestMCPTool_Execute_ServerError(t *testing.T) { // TestMCPTool_Execute_MultipleContent tests execution with multiple content items func TestMCPTool_Execute_MultipleContent(t *testing.T) { manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: "First line"}, @@ -354,7 +358,7 @@ func TestMCPTool_Execute_MultipleContent(t *testing.T) { mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() - result := mcpTool.Execute(ctx, map[string]interface{}{}) + result := mcpTool.Execute(ctx, map[string]any{}) if result.IsError { t.Errorf("Expected no error, got: %s", result.ForLLM) @@ -448,10 +452,10 @@ func TestMCPTool_InterfaceCompliance(t *testing.T) { // TestMCPTool_Parameters_MapSchema tests schema that's already a map func TestMCPTool_Parameters_MapSchema(t *testing.T) { manager := &MockMCPManager{} - schema := map[string]interface{}{ + schema := map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ + "properties": map[string]any{ + "name": map[string]any{ "type": "string", "description": "The name parameter", }, @@ -472,12 +476,12 @@ func TestMCPTool_Parameters_MapSchema(t *testing.T) { t.Errorf("Expected type 'object', got '%v'", params["type"]) } - props, ok := params["properties"].(map[string]interface{}) + props, ok := params["properties"].(map[string]any) if !ok { t.Error("Properties should be a map") } - nameParam, ok := props["name"].(map[string]interface{}) + nameParam, ok := props["name"].(map[string]any) if !ok { t.Error("Name parameter should exist") } From ef738f478741baeba5c8fe77d69a8b76a981a935 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 1 Mar 2026 10:56:02 +0800 Subject: [PATCH 49/82] fix: address PR review feedback for MCP tools support - Avoid logging sensitive cfg.Args in ConnectServer; log args_count instead - Sanitize server/tool name components in MCPTool.Name() to ensure valid identifiers for downstream providers (lowercase, [a-z0-9_-] only) - Add slack as 5th MCP server example in config.example.json - Move Dockerfile.full and docker-compose.full.yml into docker/ directory for consistency with existing docker/Dockerfile and docker/docker-compose.yml - Fix all Makefile docker-* targets to reference correct compose file paths - Fix docker/docker-compose.full.yml build context (.. ) and volume paths - Fix scripts/test-docker-mcp.sh compose file path and replace cowsay test with actual @modelcontextprotocol/server-filesystem MCP server test --- Makefile | 16 ++--- config/config.example.json | 12 ++++ Dockerfile.full => docker/Dockerfile.full | 0 .../docker-compose.full.yml | 16 ++--- pkg/mcp/manager.go | 6 +- pkg/tools/mcp_tool.go | 61 ++++++++++++++++++- scripts/test-docker-mcp.sh | 6 +- 7 files changed, 93 insertions(+), 24 deletions(-) rename Dockerfile.full => docker/Dockerfile.full (100%) rename docker-compose.full.yml => docker/docker-compose.full.yml (78%) diff --git a/Makefile b/Makefile index e2ef23e72..4da92a92b 100644 --- a/Makefile +++ b/Makefile @@ -207,12 +207,12 @@ run: build ## docker-build: Build Docker image (minimal Alpine-based) docker-build: @echo "Building minimal Docker image (Alpine-based)..." - docker compose build picoclaw-agent picoclaw-gateway + docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway ## docker-build-full: Build Docker image with full MCP support (Node.js 24) docker-build-full: @echo "Building full-featured Docker image (Node.js 24)..." - docker compose -f docker-compose.full.yml build picoclaw-agent picoclaw-gateway + docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway ## docker-test: Test MCP tools in Docker container docker-test: @@ -222,24 +222,24 @@ docker-test: ## docker-run: Run picoclaw gateway in Docker (Alpine-based) docker-run: - docker compose --profile gateway up + docker compose -f docker/docker-compose.yml --profile gateway up ## docker-run-full: Run picoclaw gateway in Docker (full-featured) docker-run-full: - docker compose -f docker-compose.full.yml --profile gateway up + docker compose -f docker/docker-compose.full.yml --profile gateway up ## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based) docker-run-agent: - docker compose run --rm picoclaw-agent + docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured) docker-run-agent-full: - docker compose -f docker-compose.full.yml run --rm picoclaw-agent + docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent ## docker-clean: Clean Docker images and volumes docker-clean: - docker compose down -v - docker compose -f docker-compose.full.yml down -v + docker compose -f docker/docker-compose.yml down -v + docker compose -f docker/docker-compose.full.yml down -v docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true ## help: Show this help message diff --git a/config/config.example.json b/config/config.example.json index 762bb90aa..c4f901caf 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -279,6 +279,18 @@ "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] + }, + "slack": { + "enabled": false, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } } } }, diff --git a/Dockerfile.full b/docker/Dockerfile.full similarity index 100% rename from Dockerfile.full rename to docker/Dockerfile.full diff --git a/docker-compose.full.yml b/docker/docker-compose.full.yml similarity index 78% rename from docker-compose.full.yml rename to docker/docker-compose.full.yml index ff2694173..6f34448c4 100644 --- a/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -1,17 +1,17 @@ services: # ───────────────────────────────────────────── # PicoClaw Agent (one-shot query) - Full MCP Support - # docker compose -f docker-compose.full.yml run --rm picoclaw-agent -m "Hello" + # docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello" # ───────────────────────────────────────────── picoclaw-agent: build: - context: . - dockerfile: Dockerfile.full + context: .. + dockerfile: docker/Dockerfile.full container_name: picoclaw-agent-full profiles: - agent volumes: - - ./config/config.json:/root/.picoclaw/config.json:ro + - ../config/config.json:/root/.picoclaw/config.json:ro - picoclaw-workspace:/root/.picoclaw/workspace - picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs entrypoint: ["picoclaw", "agent"] @@ -20,19 +20,19 @@ services: # ───────────────────────────────────────────── # PicoClaw Gateway (Long-running Bot) - Full MCP Support - # docker compose -f docker-compose.full.yml --profile gateway up + # docker compose -f docker/docker-compose.full.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: build: - context: . - dockerfile: Dockerfile.full + context: .. + dockerfile: docker/Dockerfile.full container_name: picoclaw-gateway-full restart: unless-stopped profiles: - gateway volumes: # Configuration file - - ./config/config.json:/root/.picoclaw/config.json:ro + - ../config/config.json:/root/.picoclaw/config.json:ro # Persistent workspace (sessions, memory, logs) - picoclaw-workspace:/root/.picoclaw/workspace # NPM cache for faster MCP server installs diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index e79c9d3f8..8b6d6d9aa 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -243,9 +243,9 @@ func (m *Manager) ConnectServer( ) error { logger.InfoCF("mcp", "Connecting to MCP server", map[string]any{ - "server": name, - "command": cfg.Command, - "args": cfg.Args, + "server": name, + "command": cfg.Command, + "args_count": len(cfg.Args), }) // Create client diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index 69615b4bc..1bdf248f7 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -35,10 +35,67 @@ func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool } } +// sanitizeIdentifierComponent normalizes a string so it can be safely used +// as part of a tool/function identifier for downstream providers. +// It: +// - lowercases the string +// - replaces any character not in [a-z0-9_-] with '_' +// - collapses multiple consecutive '_' into a single '_' +// - trims leading/trailing '_' +// - falls back to "unnamed" if the result is empty +// - truncates overly long components to a reasonable length +func sanitizeIdentifierComponent(s string) string { + const maxLen = 64 + + s = strings.ToLower(s) + var b strings.Builder + b.Grow(len(s)) + + prevUnderscore := false + for _, r := range s { + isAllowed := (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + + if !isAllowed { + // Normalize any disallowed character to '_' + if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + continue + } + + if r == '_' { + if prevUnderscore { + continue + } + prevUnderscore = true + } else { + prevUnderscore = false + } + + b.WriteRune(r) + } + + result := strings.Trim(b.String(), "_") + if result == "" { + result = "unnamed" + } + + if len(result) > maxLen { + result = result[:maxLen] + } + + return result +} + // Name returns the tool name, prefixed with the server name func (t *MCPTool) Name() string { - // Prefix with server name to avoid conflicts - return fmt.Sprintf("mcp_%s_%s", t.serverName, t.tool.Name) + // Prefix with server name to avoid conflicts, and sanitize components + sanitizedServer := sanitizeIdentifierComponent(t.serverName) + sanitizedTool := sanitizeIdentifierComponent(t.tool.Name) + return fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool) } // Description returns the tool description diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh index 5c4e5cb56..7ad8640f2 100755 --- a/scripts/test-docker-mcp.sh +++ b/scripts/test-docker-mcp.sh @@ -3,7 +3,7 @@ set -e -COMPOSE_FILE="docker-compose.full.yml" +COMPOSE_FILE="docker/docker-compose.full.yml" SERVICE="picoclaw-agent" echo "🧪 Testing MCP tools in Docker container (full-featured image)..." @@ -38,8 +38,8 @@ echo "✅ Testing uv..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version' # Test MCP server installation (quick) -echo "✅ Testing MCP server install with npx..." -docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx -y cowsay "MCP works!"' +echo "✅ Testing @modelcontextprotocol/server-filesystem MCP server install with npx..." +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx -y @modelcontextprotocol/server-filesystem --help' echo "" echo "🎉 All MCP tools are working correctly!" From 0eec640c3763a4e3691dfc940348f71da4e324a4 Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 1 Mar 2026 11:24:12 +0800 Subject: [PATCH 50/82] fix: correct MCP server install test in test-docker-mcp.sh server-filesystem does not support --help; use timeout + /dev/null stdin to verify installation and startup without hanging the test script --- scripts/test-docker-mcp.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh index 7ad8640f2..4eb77dceb 100755 --- a/scripts/test-docker-mcp.sh +++ b/scripts/test-docker-mcp.sh @@ -39,7 +39,7 @@ docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --v # Test MCP server installation (quick) echo "✅ Testing @modelcontextprotocol/server-filesystem MCP server install with npx..." -docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx -y @modelcontextprotocol/server-filesystem --help' +docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c ' Date: Sun, 1 Mar 2026 12:00:26 +0800 Subject: [PATCH 51/82] fix: improve MCP tool name collision safety and registry overwrite warning - MCPTool.Name(): append FNV-32a hash of original (unsanitized) server+tool names whenever sanitization is lossy or total length exceeds 64 chars, ensuring names that differ only in disallowed characters remain distinct - ToolRegistry.Register(): emit warn log when a tool registration overwrites an existing tool with the same name, making collisions observable - scripts/test-docker-mcp.sh: switch shebang from #/bin/bash /Users/yuchou/Work/klook-calendar/klook-google-cal-sync/src/googlecalconversrv/bin/start.sh to # for portability on minimal distros and Nix environments --- pkg/tools/mcp_tool.go | 30 ++++++++++++++++++++++++++++-- pkg/tools/registry.go | 7 ++++++- scripts/test-docker-mcp.sh | 2 +- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index 1bdf248f7..6e53cf354 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "hash/fnv" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -90,12 +91,37 @@ func sanitizeIdentifierComponent(s string) string { return result } -// Name returns the tool name, prefixed with the server name +// Name returns the tool name, prefixed with the server name. +// The total length is capped at 64 characters (OpenAI-compatible API limit). +// A short hash of the original (unsanitized) server and tool names is appended +// whenever sanitization is lossy or the name is truncated, ensuring that two +// names which differ only in disallowed characters remain distinct after sanitization. func (t *MCPTool) Name() string { // Prefix with server name to avoid conflicts, and sanitize components sanitizedServer := sanitizeIdentifierComponent(t.serverName) sanitizedTool := sanitizeIdentifierComponent(t.tool.Name) - return fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool) + full := fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool) + + // Check if sanitization was lossless (only lowercasing, no char replacement/truncation) + lossless := strings.ToLower(t.serverName) == sanitizedServer && + strings.ToLower(t.tool.Name) == sanitizedTool + + const maxTotal = 64 + if lossless && len(full) <= maxTotal { + return full + } + + // Sanitization was lossy or name too long: append hash of the ORIGINAL names + // (not the sanitized names) so different originals always yield different hashes. + h := fnv.New32a() + _, _ = h.Write([]byte(t.serverName + "\x00" + t.tool.Name)) + suffix := fmt.Sprintf("%08x", h.Sum32()) // 8 chars + + base := full + if len(base) > maxTotal-9 { + base = strings.TrimRight(full[:maxTotal-9], "_") + } + return base + "_" + suffix } // Description returns the tool description diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index d37a093a8..0ba983e02 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -25,7 +25,12 @@ func NewToolRegistry() *ToolRegistry { func (r *ToolRegistry) Register(tool Tool) { r.mu.Lock() defer r.mu.Unlock() - r.tools[tool.Name()] = tool + name := tool.Name() + if _, exists := r.tools[name]; exists { + logger.WarnCF("tools", "Tool registration overwrites existing tool", + map[string]any{"name": name}) + } + r.tools[name] = tool } func (r *ToolRegistry) Get(name string) (Tool, bool) { diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh index 4eb77dceb..9d582ffa0 100755 --- a/scripts/test-docker-mcp.sh +++ b/scripts/test-docker-mcp.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Test script for MCP tools in Docker (full-featured image) set -e From 3d54a77c407404e2043fa3af568fef5321a21ad8 Mon Sep 17 00:00:00 2001 From: Zachary Guerrero Date: Fri, 20 Feb 2026 14:32:58 -0800 Subject: [PATCH 52/82] feat: add Media field to Message struct and implement serializeMessages for vision API support - Add Media []string field to Message struct for image/media URLs - Implement serializeMessages() to format messages with image_url content parts - Enables OpenAI-compatible vision APIs to receive image attachments --- pkg/providers/openai_compat/provider.go | 43 ++++++++++++++++++++++++- pkg/providers/protocoltypes/types.go | 1 + 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 74e612046..726a34dee 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -116,7 +116,7 @@ func (p *Provider) Chat( requestBody := map[string]any{ "model": model, - "messages": stripSystemParts(messages), + "messages": serializeMessages(messages), } if len(tools) > 0 { @@ -195,6 +195,47 @@ func (p *Provider) Chat( return parseResponse(body) } +func serializeMessages(messages []Message) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(messages)) + for _, m := range messages { + if len(m.Media) == 0 { + msg := map[string]interface{}{ + "role": m.Role, + "content": m.Content, + } + if m.ToolCallID != "" { + msg["tool_call_id"] = m.ToolCallID + } + if len(m.ToolCalls) > 0 { + msg["tool_calls"] = m.ToolCalls + } + result = append(result, msg) + continue + } + + parts := make([]map[string]interface{}, 0, 1+len(m.Media)) + if m.Content != "" { + parts = append(parts, map[string]interface{}{ + "type": "text", + "text": m.Content, + }) + } + for _, mediaURL := range m.Media { + parts = append(parts, map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": mediaURL, + }, + }) + } + result = append(result, map[string]interface{}{ + "role": m.Role, + "content": parts, + }) + } + return result +} + func parseResponse(body []byte) (*LLMResponse, error) { var apiResponse struct { Choices []struct { diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 99f13334e..efac1e10b 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -65,6 +65,7 @@ type ContentBlock struct { type Message struct { Role string `json:"role"` Content string `json:"content"` + Media []string `json:"media,omitempty"` // URLs of images or other media attachments ReasoningContent string `json:"reasoning_content,omitempty"` SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` From 6997edc82e1cd555187f982701d03ebc51e26bba Mon Sep 17 00:00:00 2001 From: shikihane Date: Sun, 1 Mar 2026 19:19:31 +0800 Subject: [PATCH 53/82] feat(agent): wire Media through agent pipeline (cherry-pick PR #555) Add Media field to processOptions, pass msg.Media from inbound messages through to BuildMessages and serializeMessages so vision-capable LLMs receive image_url content parts. Based on work by @as3k in sipeed/picoclaw#555. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/context.go | 3 ++- pkg/agent/loop.go | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 6fccbaf53..8868d6bf4 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -465,10 +465,11 @@ func (cb *ContextBuilder) BuildMessages( messages = append(messages, history...) // Add current user message - if strings.TrimSpace(currentMessage) != "" { + if strings.TrimSpace(currentMessage) != "" || len(media) > 0 { messages = append(messages, providers.Message{ Role: "user", Content: currentMessage, + Media: media, }) } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 00b0f096a..52a72d0f1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -46,11 +46,12 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - UserMessage string // User message content (may include prefix) - DefaultResponse string // Response when LLM returns empty + SessionKey string // Session identifier for history/context + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + UserMessage string // User message content (may include prefix) + Media []string // Media URLs attached to the user message + DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus NoHistory bool // If true, don't load session history (for heartbeat) @@ -417,6 +418,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, + Media: msg.Media, DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, @@ -509,7 +511,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt history, summary, opts.UserMessage, - nil, + opts.Media, opts.Channel, opts.ChatID, ) From a4e5c391bd67357a52936e1e3081b97a01edffb8 Mon Sep 17 00:00:00 2001 From: shikihane Date: Mon, 2 Mar 2026 17:38:08 +0800 Subject: [PATCH 54/82] fix(openai_compat): preserve reasoning_content in serializeMessages The serializeMessages() function was not preserving the reasoning_content field when serializing messages for vision API calls. This caused the TestProviderChat_PreservesReasoningContentInHistory test to fail. This fix ensures reasoning_content is included in both text-only messages and vision messages with media attachments. Co-authored-by: Zachary Guerrero --- pkg/providers/openai_compat/provider.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 726a34dee..f6c5a3664 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -209,6 +209,9 @@ func serializeMessages(messages []Message) []map[string]interface{} { if len(m.ToolCalls) > 0 { msg["tool_calls"] = m.ToolCalls } + if m.ReasoningContent != "" { + msg["reasoning_content"] = m.ReasoningContent + } result = append(result, msg) continue } @@ -228,10 +231,14 @@ func serializeMessages(messages []Message) []map[string]interface{} { }, }) } - result = append(result, map[string]interface{}{ + msg := map[string]interface{}{ "role": m.Role, "content": parts, - }) + } + if m.ReasoningContent != "" { + msg["reasoning_content"] = m.ReasoningContent + } + result = append(result, msg) } return result } From 18b36af9342b648b0dd1e2ba6d9f56866af76744 Mon Sep 17 00:00:00 2001 From: shikihane Date: Mon, 2 Mar 2026 18:08:32 +0800 Subject: [PATCH 55/82] feat(agent): add resolveMediaRefs to convert media:// refs to base64 data URLs Without this function, media:// refs stored by MediaStore are passed directly to the LLM API, which rejects them as invalid URLs. resolveMediaRefs() runs after BuildMessages() and before the LLM call, converting each media:// ref to a data:image/...;base64,... URL that vision-capable models can process. Also adds mimeFromExtension() helper for MIME type inference from file extensions when ContentType metadata is not available. --- pkg/agent/loop.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 52a72d0f1..36a3ad508 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -8,9 +8,11 @@ package agent import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" + "os" "path/filepath" "strings" "sync" @@ -515,6 +517,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt opts.Channel, opts.ChatID, ) + messages = resolveMediaRefs(messages, al.mediaStore) // 3. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) @@ -1352,3 +1355,76 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { } return &routing.RoutePeer{Kind: parentKind, ID: parentID} } + +// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs. +// Returns a new slice with resolved URLs; original messages are not mutated. +func resolveMediaRefs(messages []providers.Message, store media.MediaStore) []providers.Message { + if store == nil { + return messages + } + + result := make([]providers.Message, len(messages)) + copy(result, messages) + + for i, m := range result { + if len(m.Media) == 0 { + continue + } + + resolved := make([]string, 0, len(m.Media)) + for _, ref := range m.Media { + if !strings.HasPrefix(ref, "media://") { + resolved = append(resolved, ref) + continue + } + + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ + "ref": ref, + "error": err.Error(), + }) + continue + } + + data, err := os.ReadFile(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to read media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + + mime := meta.ContentType + if mime == "" { + mime = mimeFromExtension(filepath.Ext(localPath)) + } + + dataURL := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) + resolved = append(resolved, dataURL) + } + + result[i].Media = resolved + } + + return result +} + +// mimeFromExtension returns a MIME type for common image extensions. +func mimeFromExtension(ext string) string { + switch strings.ToLower(ext) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".bmp": + return "image/bmp" + default: + return "image/jpeg" + } +} From 18d89937ad487bd467b64aa798e5ec4560adecb7 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Tue, 24 Feb 2026 14:29:17 +0300 Subject: [PATCH 56/82] fix(wecom): remove message-dedupe data races in bot/app channels Centralize dedupe map access behind a mutex-safe helper and use it in both WeCom bot and WeCom app channels to eliminate concurrent map access races while preserving current dedupe behavior. --- pkg/channels/wecom/app.go | 13 +------------ pkg/channels/wecom/bot.go | 13 +------------ pkg/channels/wecom/dedupe.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 pkg/channels/wecom/dedupe.go diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index b79340315..c550d8b47 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -607,23 +607,12 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag // Message deduplication: Use msg_id to prevent duplicate processing // As per WeCom documentation, use msg_id for deduplication msgID := fmt.Sprintf("%d", msg.MsgId) - c.msgMu.Lock() - if c.processedMsgs[msgID] { - c.msgMu.Unlock() + if !markMessageProcessed(&c.msgMu, &c.processedMsgs, msgID, wecomMaxProcessedMessages) { logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return } - c.processedMsgs[msgID] = true - // Clean up old messages while still holding the lock to avoid a data race - // on len(). Reset the map but re-insert the current msgID so it remains - // deduplicated. - if len(c.processedMsgs) > 1000 { - c.processedMsgs = make(map[string]bool) - c.processedMsgs[msgID] = true - } - c.msgMu.Unlock() senderID := msg.FromUserName chatID := senderID // WeCom App uses user ID as chat ID for direct messages diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 0d0426c0d..c1bdf6c25 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -330,23 +330,12 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag // Message deduplication: Use msg_id to prevent duplicate processing msgID := msg.MsgID - c.msgMu.Lock() - if c.processedMsgs[msgID] { - c.msgMu.Unlock() + if !markMessageProcessed(&c.msgMu, &c.processedMsgs, msgID, wecomMaxProcessedMessages) { logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return } - c.processedMsgs[msgID] = true - // Clean up old messages while still holding the lock to avoid a data race - // on len(). Reset the map but re-insert the current msgID so it remains - // deduplicated. - if len(c.processedMsgs) > 1000 { - c.processedMsgs = make(map[string]bool) - c.processedMsgs[msgID] = true - } - c.msgMu.Unlock() senderID := msg.From.UserID diff --git a/pkg/channels/wecom/dedupe.go b/pkg/channels/wecom/dedupe.go new file mode 100644 index 000000000..5f2b4cf81 --- /dev/null +++ b/pkg/channels/wecom/dedupe.go @@ -0,0 +1,28 @@ +package wecom + +import "sync" + +const wecomMaxProcessedMessages = 1000 + +// markMessageProcessed marks msgID as processed and returns false for duplicates. +// All map reads/writes (including len) are protected by msgMu to avoid races. +func markMessageProcessed(msgMu *sync.RWMutex, processedMsgs *map[string]bool, msgID string, maxEntries int) bool { + if maxEntries <= 0 { + maxEntries = wecomMaxProcessedMessages + } + + msgMu.Lock() + defer msgMu.Unlock() + + if (*processedMsgs)[msgID] { + return false + } + (*processedMsgs)[msgID] = true + + // Keep the newest message marker when rotating to bound memory growth. + if len(*processedMsgs) > maxEntries { + *processedMsgs = map[string]bool{msgID: true} + } + + return true +} From db17cdc86deeb37927ed39e11919dabfcca0a03c Mon Sep 17 00:00:00 2001 From: esubaalew Date: Tue, 24 Feb 2026 14:48:58 +0300 Subject: [PATCH 57/82] test(wecom): align dedupe rotation behavior and add helper tests Match rotation semantics to prior behavior by fully resetting the dedupe map once the size limit is exceeded, and add focused tests for duplicate detection and boundary rotation behavior. --- pkg/channels/wecom/dedupe.go | 4 +-- pkg/channels/wecom_dedupe_test.go | 42 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 pkg/channels/wecom_dedupe_test.go diff --git a/pkg/channels/wecom/dedupe.go b/pkg/channels/wecom/dedupe.go index 5f2b4cf81..09f5a8a41 100644 --- a/pkg/channels/wecom/dedupe.go +++ b/pkg/channels/wecom/dedupe.go @@ -19,9 +19,9 @@ func markMessageProcessed(msgMu *sync.RWMutex, processedMsgs *map[string]bool, m } (*processedMsgs)[msgID] = true - // Keep the newest message marker when rotating to bound memory growth. + // Keep existing behavior: when over limit, reset dedupe map entirely. if len(*processedMsgs) > maxEntries { - *processedMsgs = map[string]bool{msgID: true} + *processedMsgs = make(map[string]bool) } return true diff --git a/pkg/channels/wecom_dedupe_test.go b/pkg/channels/wecom_dedupe_test.go new file mode 100644 index 000000000..d26c3388b --- /dev/null +++ b/pkg/channels/wecom_dedupe_test.go @@ -0,0 +1,42 @@ +package channels + +import ( + "sync" + "testing" +) + +func TestMarkMessageProcessed_DuplicateDetection(t *testing.T) { + var mu sync.RWMutex + processed := make(map[string]bool) + + if ok := markMessageProcessed(&mu, &processed, "msg-1", 1000); !ok { + t.Fatalf("first message should be accepted") + } + + if ok := markMessageProcessed(&mu, &processed, "msg-1", 1000); ok { + t.Fatalf("duplicate message should be rejected") + } +} + +func TestMarkMessageProcessed_RotationClearsMapAtBoundary(t *testing.T) { + var mu sync.RWMutex + processed := make(map[string]bool) + + if ok := markMessageProcessed(&mu, &processed, "msg-1", 1); !ok { + t.Fatalf("first message should be accepted") + } + if len(processed) != 1 { + t.Fatalf("expected map size 1 after first insert, got %d", len(processed)) + } + + // Inserting second unique message exceeds maxEntries and should reset map. + if ok := markMessageProcessed(&mu, &processed, "msg-2", 1); !ok { + t.Fatalf("second unique message should be accepted") + } + if len(processed) != 0 { + t.Fatalf("expected map to be reset after rotation, got size %d", len(processed)) + } + if processed["msg-2"] { + t.Fatalf("expected current message marker to be cleared after rotation") + } +} From 1e2ab4a5e51b08536c76ba4b0783ad560b6971c6 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Tue, 24 Feb 2026 14:55:24 +0300 Subject: [PATCH 58/82] test(wecom): add dedupe helper coverage and align constant usage Use wecomMaxProcessedMessages in tests and add a concurrent same-message test to lock in race-safety behavior for markMessageProcessed. --- pkg/channels/wecom_dedupe_test.go | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/pkg/channels/wecom_dedupe_test.go b/pkg/channels/wecom_dedupe_test.go index d26c3388b..467f79979 100644 --- a/pkg/channels/wecom_dedupe_test.go +++ b/pkg/channels/wecom_dedupe_test.go @@ -9,15 +9,46 @@ func TestMarkMessageProcessed_DuplicateDetection(t *testing.T) { var mu sync.RWMutex processed := make(map[string]bool) - if ok := markMessageProcessed(&mu, &processed, "msg-1", 1000); !ok { + if ok := markMessageProcessed(&mu, &processed, "msg-1", wecomMaxProcessedMessages); !ok { t.Fatalf("first message should be accepted") } - if ok := markMessageProcessed(&mu, &processed, "msg-1", 1000); ok { + if ok := markMessageProcessed(&mu, &processed, "msg-1", wecomMaxProcessedMessages); ok { t.Fatalf("duplicate message should be rejected") } } +func TestMarkMessageProcessed_ConcurrentSameMessage(t *testing.T) { + var mu sync.RWMutex + processed := make(map[string]bool) + + const goroutines = 64 + var wg sync.WaitGroup + wg.Add(goroutines) + + results := make(chan bool, goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + results <- markMessageProcessed(&mu, &processed, "msg-concurrent", wecomMaxProcessedMessages) + }() + } + + wg.Wait() + close(results) + + successes := 0 + for ok := range results { + if ok { + successes++ + } + } + + if successes != 1 { + t.Fatalf("expected exactly 1 successful mark, got %d", successes) + } +} + func TestMarkMessageProcessed_RotationClearsMapAtBoundary(t *testing.T) { var mu sync.RWMutex processed := make(map[string]bool) From 8640c8177ca544558fa6c89eeb76b72e084c7fd9 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Tue, 24 Feb 2026 15:18:54 +0300 Subject: [PATCH 59/82] fix(wecom): correctly retain boundary message during dedupe map rotation When the dedupe map rotates, the previous logic entirely cleared the map, meaning the message that triggered the rotation was immediately forgotten and could be duplicated immediately. This change seeds the new map with the current message to prevent that. Also adds a defensive nil check. --- pkg/channels/wecom/dedupe.go | 8 ++++++-- pkg/channels/wecom_dedupe_test.go | 15 ++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pkg/channels/wecom/dedupe.go b/pkg/channels/wecom/dedupe.go index 09f5a8a41..8ca98a30d 100644 --- a/pkg/channels/wecom/dedupe.go +++ b/pkg/channels/wecom/dedupe.go @@ -14,14 +14,18 @@ func markMessageProcessed(msgMu *sync.RWMutex, processedMsgs *map[string]bool, m msgMu.Lock() defer msgMu.Unlock() + if *processedMsgs == nil { + *processedMsgs = make(map[string]bool) + } + if (*processedMsgs)[msgID] { return false } (*processedMsgs)[msgID] = true - // Keep existing behavior: when over limit, reset dedupe map entirely. + // When over limit, reset dedupe map but keep the current message. if len(*processedMsgs) > maxEntries { - *processedMsgs = make(map[string]bool) + *processedMsgs = map[string]bool{msgID: true} } return true diff --git a/pkg/channels/wecom_dedupe_test.go b/pkg/channels/wecom_dedupe_test.go index 467f79979..71f987892 100644 --- a/pkg/channels/wecom_dedupe_test.go +++ b/pkg/channels/wecom_dedupe_test.go @@ -60,14 +60,19 @@ func TestMarkMessageProcessed_RotationClearsMapAtBoundary(t *testing.T) { t.Fatalf("expected map size 1 after first insert, got %d", len(processed)) } - // Inserting second unique message exceeds maxEntries and should reset map. + // Inserting second unique message exceeds maxEntries and should reset map, but keep the new message. if ok := markMessageProcessed(&mu, &processed, "msg-2", 1); !ok { t.Fatalf("second unique message should be accepted") } - if len(processed) != 0 { - t.Fatalf("expected map to be reset after rotation, got size %d", len(processed)) + if len(processed) != 1 { + t.Fatalf("expected map to retain current message after rotation, got size %d", len(processed)) } - if processed["msg-2"] { - t.Fatalf("expected current message marker to be cleared after rotation") + if !processed["msg-2"] { + t.Fatalf("expected current message marker to be retained after rotation") + } + + // Because msg-2 was retained, an immediate duplicate should be rejected. + if ok := markMessageProcessed(&mu, &processed, "msg-2", 1); ok { + t.Fatalf("duplicate message immediately after rotation should be rejected") } } From 29e9b6b4b5c1d6055b4e1e695c83faf1324b383e Mon Sep 17 00:00:00 2001 From: esubaalew Date: Tue, 24 Feb 2026 15:56:28 +0300 Subject: [PATCH 60/82] fix(wecom): replace dedupe map rotation with circular queue The previous dedupe map rotation logic completely cleared the map when it reached max size, causing an 'amnesia cliff' where immediately arriving duplicates of just-forgotten messages would be processed. This change replaces that with a MessageDeduplicator struct that uses a circular queue (ring buffer) to track insertions. When the limit is reached, it only evicts the absolute oldest message from the map, completely resolving the cliff issue. This also cleans up the WeCom Bot and App webhook handlers by encapsulating the mutex and map state. --- pkg/channels/wecom/app.go | 7 ++-- pkg/channels/wecom/bot.go | 8 ++-- pkg/channels/wecom/dedupe.go | 50 +++++++++++++++++------- pkg/channels/wecom_dedupe_test.go | 63 +++++++++++++++++-------------- 4 files changed, 76 insertions(+), 52 deletions(-) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index c550d8b47..717815b9f 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -38,8 +38,7 @@ type WeComAppChannel struct { tokenMu sync.RWMutex ctx context.Context cancel context.CancelFunc - processedMsgs map[string]bool // Message deduplication: msg_id -> processed - msgMu sync.RWMutex + processedMsgs *MessageDeduplicator } // WeComXMLMessage represents the XML message structure from WeCom @@ -144,7 +143,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) ( client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, - processedMsgs: make(map[string]bool), + processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), }, nil } @@ -607,7 +606,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag // Message deduplication: Use msg_id to prevent duplicate processing // As per WeCom documentation, use msg_id for deduplication msgID := fmt.Sprintf("%d", msg.MsgId) - if !markMessageProcessed(&c.msgMu, &c.processedMsgs, msgID, wecomMaxProcessedMessages) { + if !c.processedMsgs.MarkMessageProcessed(msgID) { logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index c1bdf6c25..9126a847d 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "strings" - "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" @@ -28,8 +27,7 @@ type WeComBotChannel struct { client *http.Client ctx context.Context cancel context.CancelFunc - processedMsgs map[string]bool // Message deduplication: msg_id -> processed - msgMu sync.RWMutex + processedMsgs *MessageDeduplicator } // WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) @@ -108,7 +106,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, - processedMsgs: make(map[string]bool), + processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), }, nil } @@ -330,7 +328,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag // Message deduplication: Use msg_id to prevent duplicate processing msgID := msg.MsgID - if !markMessageProcessed(&c.msgMu, &c.processedMsgs, msgID, wecomMaxProcessedMessages) { + if !c.processedMsgs.MarkMessageProcessed(msgID) { logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) diff --git a/pkg/channels/wecom/dedupe.go b/pkg/channels/wecom/dedupe.go index 8ca98a30d..865be668e 100644 --- a/pkg/channels/wecom/dedupe.go +++ b/pkg/channels/wecom/dedupe.go @@ -4,29 +4,51 @@ import "sync" const wecomMaxProcessedMessages = 1000 -// markMessageProcessed marks msgID as processed and returns false for duplicates. -// All map reads/writes (including len) are protected by msgMu to avoid races. -func markMessageProcessed(msgMu *sync.RWMutex, processedMsgs *map[string]bool, msgID string, maxEntries int) bool { +// MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer) +// combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest +// messages without causing "amnesia cliffs" when the limit is reached. +type MessageDeduplicator struct { + mu sync.Mutex + msgs map[string]bool + ring []string + idx int + max int +} + +// NewMessageDeduplicator creates a new deduplicator with the specified capacity. +func NewMessageDeduplicator(maxEntries int) *MessageDeduplicator { if maxEntries <= 0 { maxEntries = wecomMaxProcessedMessages } - - msgMu.Lock() - defer msgMu.Unlock() - - if *processedMsgs == nil { - *processedMsgs = make(map[string]bool) + return &MessageDeduplicator{ + msgs: make(map[string]bool, maxEntries), + ring: make([]string, maxEntries), + max: maxEntries, } +} - if (*processedMsgs)[msgID] { +// MarkMessageProcessed marks msgID as processed and returns false for duplicates. +func (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool { + d.mu.Lock() + defer d.mu.Unlock() + + // 1. Check for duplicate + if d.msgs[msgID] { return false } - (*processedMsgs)[msgID] = true - // When over limit, reset dedupe map but keep the current message. - if len(*processedMsgs) > maxEntries { - *processedMsgs = map[string]bool{msgID: true} + // 2. Evict the oldest message at our current ring position (if any) + oldestID := d.ring[d.idx] + if oldestID != "" { + delete(d.msgs, oldestID) } + // 3. Store the new message + d.msgs[msgID] = true + d.ring[d.idx] = msgID + + // 4. Advance the circle queue index + d.idx = (d.idx + 1) % d.max + return true } diff --git a/pkg/channels/wecom_dedupe_test.go b/pkg/channels/wecom_dedupe_test.go index 71f987892..41a50f7e2 100644 --- a/pkg/channels/wecom_dedupe_test.go +++ b/pkg/channels/wecom_dedupe_test.go @@ -5,22 +5,20 @@ import ( "testing" ) -func TestMarkMessageProcessed_DuplicateDetection(t *testing.T) { - var mu sync.RWMutex - processed := make(map[string]bool) +func TestMessageDeduplicator_DuplicateDetection(t *testing.T) { + d := NewMessageDeduplicator(wecomMaxProcessedMessages) - if ok := markMessageProcessed(&mu, &processed, "msg-1", wecomMaxProcessedMessages); !ok { + if ok := d.MarkMessageProcessed("msg-1"); !ok { t.Fatalf("first message should be accepted") } - if ok := markMessageProcessed(&mu, &processed, "msg-1", wecomMaxProcessedMessages); ok { + if ok := d.MarkMessageProcessed("msg-1"); ok { t.Fatalf("duplicate message should be rejected") } } -func TestMarkMessageProcessed_ConcurrentSameMessage(t *testing.T) { - var mu sync.RWMutex - processed := make(map[string]bool) +func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) { + d := NewMessageDeduplicator(wecomMaxProcessedMessages) const goroutines = 64 var wg sync.WaitGroup @@ -30,7 +28,7 @@ func TestMarkMessageProcessed_ConcurrentSameMessage(t *testing.T) { for i := 0; i < goroutines; i++ { go func() { defer wg.Done() - results <- markMessageProcessed(&mu, &processed, "msg-concurrent", wecomMaxProcessedMessages) + results <- d.MarkMessageProcessed("msg-concurrent") }() } @@ -49,30 +47,37 @@ func TestMarkMessageProcessed_ConcurrentSameMessage(t *testing.T) { } } -func TestMarkMessageProcessed_RotationClearsMapAtBoundary(t *testing.T) { - var mu sync.RWMutex - processed := make(map[string]bool) +func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) { + // Create a deduplicator with a very small capacity to test eviction easily + capacity := 3 + d := NewMessageDeduplicator(capacity) - if ok := markMessageProcessed(&mu, &processed, "msg-1", 1); !ok { - t.Fatalf("first message should be accepted") - } - if len(processed) != 1 { - t.Fatalf("expected map size 1 after first insert, got %d", len(processed)) + // Fill the queue + d.MarkMessageProcessed("msg-1") + d.MarkMessageProcessed("msg-2") + d.MarkMessageProcessed("msg-3") + + // At this point, the queue is full. msg-1 is the oldest. + if len(d.msgs) != 3 { + t.Fatalf("expected map size to be 3, got %d", len(d.msgs)) } - // Inserting second unique message exceeds maxEntries and should reset map, but keep the new message. - if ok := markMessageProcessed(&mu, &processed, "msg-2", 1); !ok { - t.Fatalf("second unique message should be accepted") - } - if len(processed) != 1 { - t.Fatalf("expected map to retain current message after rotation, got size %d", len(processed)) - } - if !processed["msg-2"] { - t.Fatalf("expected current message marker to be retained after rotation") + // This should evict msg-1 and add msg-4 + if ok := d.MarkMessageProcessed("msg-4"); !ok { + t.Fatalf("msg-4 should be accepted") } - // Because msg-2 was retained, an immediate duplicate should be rejected. - if ok := markMessageProcessed(&mu, &processed, "msg-2", 1); ok { - t.Fatalf("duplicate message immediately after rotation should be rejected") + if len(d.msgs) != 3 { + t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs)) + } + + // msg-1 should now be forgotten (evicted) + if ok := d.MarkMessageProcessed("msg-1"); !ok { + t.Fatalf("msg-1 should be accepted again because it was evicted") + } + + // msg-2 should have been evicted when we added msg-1 back + if ok := d.MarkMessageProcessed("msg-2"); !ok { + t.Fatalf("msg-2 should be accepted again because it was evicted") } } From 2e0be9277660b33b4017b0c0f5e4f388ac54ac75 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Mon, 2 Mar 2026 18:54:11 +0300 Subject: [PATCH 61/82] fix(wecom): resolve upstream rebase conflicts after channel refactor Rebase onto latest upstream/main, keep ring-buffer dedupe behavior, move dedupe tests to pkg/channels/wecom, and ensure wecom/channels race tests pass. --- .../{wecom_dedupe_test.go => wecom/dedupe_test.go} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename pkg/channels/{wecom_dedupe_test.go => wecom/dedupe_test.go} (90%) diff --git a/pkg/channels/wecom_dedupe_test.go b/pkg/channels/wecom/dedupe_test.go similarity index 90% rename from pkg/channels/wecom_dedupe_test.go rename to pkg/channels/wecom/dedupe_test.go index 41a50f7e2..10dff4cfe 100644 --- a/pkg/channels/wecom_dedupe_test.go +++ b/pkg/channels/wecom/dedupe_test.go @@ -1,4 +1,4 @@ -package channels +package wecom import ( "sync" @@ -48,11 +48,11 @@ func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) { } func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) { - // Create a deduplicator with a very small capacity to test eviction easily + // Create a deduplicator with a very small capacity to test eviction easily. capacity := 3 d := NewMessageDeduplicator(capacity) - // Fill the queue + // Fill the queue. d.MarkMessageProcessed("msg-1") d.MarkMessageProcessed("msg-2") d.MarkMessageProcessed("msg-3") @@ -62,7 +62,7 @@ func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) { t.Fatalf("expected map size to be 3, got %d", len(d.msgs)) } - // This should evict msg-1 and add msg-4 + // This should evict msg-1 and add msg-4. if ok := d.MarkMessageProcessed("msg-4"); !ok { t.Fatalf("msg-4 should be accepted") } @@ -71,12 +71,12 @@ func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) { t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs)) } - // msg-1 should now be forgotten (evicted) + // msg-1 should now be forgotten (evicted). if ok := d.MarkMessageProcessed("msg-1"); !ok { t.Fatalf("msg-1 should be accepted again because it was evicted") } - // msg-2 should have been evicted when we added msg-1 back + // msg-2 should have been evicted when we added msg-1 back. if ok := d.MarkMessageProcessed("msg-2"); !ok { t.Fatalf("msg-2 should be accepted again because it was evicted") } From 78aba700d57e832d6efaa461565cb38579606375 Mon Sep 17 00:00:00 2001 From: yinwm Date: Tue, 3 Mar 2026 00:47:25 +0800 Subject: [PATCH 62/82] fix(mcp): resolve TOCTOU race condition and resource leak - Use atomic.Bool for closed flag to prevent TOCTOU race between CallTool and Close operations - Add double-check pattern in CallTool for thread-safe closed state - Use atomic Swap in Close to ensure no new calls can start after closed flag is set - Move MCP manager cleanup defer before initialization to handle partial initialization failures - Update tests to use atomic.Bool operations Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 ++-- pkg/agent/loop.go | 21 +++++++++++---------- pkg/mcp/manager.go | 24 +++++++++++++++--------- pkg/mcp/manager_test.go | 2 +- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 9f755bbc9..1c699a724 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.3.1 github.com/chzyer/readline v1.5.1 + github.com/gdamore/tcell/v2 v2.13.8 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 @@ -16,6 +17,7 @@ require ( github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 + github.com/rivo/tview v0.42.0 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -35,7 +37,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/gdamore/tcell/v2 v2.13.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -44,7 +45,6 @@ require ( github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/tview v0.42.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 88afa6119..ac9b449a2 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -178,6 +178,17 @@ func (al *AgentLoop) Run(ctx context.Context) error { // Initialize MCP servers for all agents if al.cfg.Tools.MCP.Enabled { mcpManager := mcp.NewManager() + // Ensure MCP connections are cleaned up on exit, regardless of initialization success + // This fixes resource leak when LoadFromMCPConfig partially succeeds then fails + defer func() { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": err.Error(), + }) + } + }() + defaultAgent := al.registry.GetDefaultAgent() var workspacePath string if defaultAgent != nil && defaultAgent.Workspace != "" { @@ -192,16 +203,6 @@ func (al *AgentLoop) Run(ctx context.Context) error { "error": err.Error(), }) } else { - // Ensure MCP connections are cleaned up on exit, only if initialization succeeded - defer func() { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - }() - // Register MCP tools for all agents servers := mcpManager.GetServers() uniqueTools := 0 diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 8b6d6d9aa..7b63cc979 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -108,7 +109,7 @@ type ServerConnection struct { type Manager struct { servers map[string]*ServerConnection mu sync.RWMutex - closed bool + closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race wg sync.WaitGroup // tracks in-flight CallTool calls } @@ -440,14 +441,20 @@ func (m *Manager) CallTool( serverName, toolName string, arguments map[string]any, ) (*mcp.CallToolResult, error) { + // Check if closed before acquiring lock (fast path) + if m.closed.Load() { + return nil, fmt.Errorf("manager is closed") + } + m.mu.RLock() - if m.closed { + // Double-check after acquiring lock to prevent TOCTOU race + if m.closed.Load() { m.mu.RUnlock() return nil, fmt.Errorf("manager is closed") } conn, ok := m.servers[serverName] if ok { - m.wg.Add(1) + m.wg.Add(1) // Add to WaitGroup while holding the lock } m.mu.RUnlock() @@ -471,15 +478,14 @@ func (m *Manager) CallTool( // Close closes all server connections func (m *Manager) Close() error { - m.mu.Lock() - if m.closed { - m.mu.Unlock() - return nil + // Use Swap to atomically set closed=true and get the previous value + // This prevents TOCTOU race with CallTool's closed check + if m.closed.Swap(true) { + return nil // already closed } - m.closed = true - m.mu.Unlock() // Wait for all in-flight CallTool calls to finish before closing sessions + // After closed=true is set, no new CallTool can start (they check closed first) m.wg.Wait() m.mu.Lock() diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index 6dd71a3c2..8ce81d09e 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -268,7 +268,7 @@ func TestGetAllTools_FiltersEmptyTools(t *testing.T) { func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) { t.Run("manager closed", func(t *testing.T) { mgr := NewManager() - mgr.closed = true + mgr.closed.Store(true) _, err := mgr.CallTool(context.Background(), "s1", "tool", nil) if err == nil || !strings.Contains(err.Error(), "manager is closed") { From c9fb681f3b2253142623ff4079dc5aaafa4ca3c0 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Tue, 3 Mar 2026 00:49:11 +0800 Subject: [PATCH 63/82] feat(feishu): enhance channel with markdown cards, media, mentions, and editing Upgrade the Feishu channel from basic text-only to full feature parity with Telegram/Discord: interactive card messages with markdown rendering, message editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji reactions (ReactionCapable), and inbound/outbound media support (MediaSender). Also add @mention detection with lazy bot open_id discovery, group trigger filtering with mention awareness, and multi-type inbound message parsing (text, post, image, file, audio, video). --- pkg/channels/feishu/common.go | 83 ++++ pkg/channels/feishu/feishu_32.go | 20 + pkg/channels/feishu/feishu_64.go | 655 ++++++++++++++++++++++++++++--- pkg/config/config.go | 1 + 4 files changed, 704 insertions(+), 55 deletions(-) diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index e8a057741..cbae837a8 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -1,5 +1,13 @@ package feishu +import ( + "encoding/json" + "regexp" + "strings" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + // stringValue safely dereferences a *string pointer. func stringValue(v *string) string { if v == nil { @@ -7,3 +15,78 @@ func stringValue(v *string) string { } return *v } + +// buildMarkdownCard builds a Feishu Interactive Card JSON 2.0 string with markdown content. +// JSON 2.0 cards support full CommonMark standard markdown syntax. +func buildMarkdownCard(content string) (string, error) { + card := map[string]any{ + "schema": "2.0", + "body": map[string]any{ + "elements": []map[string]any{ + { + "tag": "markdown", + "content": content, + }, + }, + }, + } + data, err := json.Marshal(card) + if err != nil { + return "", err + } + return string(data), nil +} + +// extractImageKey extracts the image_key from a Feishu image message content JSON. +// Format: {"image_key": "img_xxx"} +func extractImageKey(content string) string { + var payload struct { + ImageKey string `json:"image_key"` + } + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return "" + } + return payload.ImageKey +} + +// extractFileKey extracts the file_key from a Feishu file/audio message content JSON. +// Format: {"file_key": "file_xxx", "file_name": "...", ...} +func extractFileKey(content string) string { + var payload struct { + FileKey string `json:"file_key"` + } + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return "" + } + return payload.FileKey +} + +// extractFileName extracts the file_name from a Feishu file message content JSON. +func extractFileName(content string) string { + var payload struct { + FileName string `json:"file_name"` + } + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return "" + } + return payload.FileName +} + +// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. +var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`) + +// stripMentionPlaceholders removes @_user_N placeholders from the text content. +// These are inserted by Feishu when users @mention someone in a message. +func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string { + if len(mentions) == 0 { + return content + } + for _, m := range mentions { + if m.Key != nil && *m.Key != "" { + content = strings.ReplaceAll(content, *m.Key, "") + } + } + // Also clean up any remaining @_user_N patterns + content = mentionPlaceholderRegex.ReplaceAllString(content, "") + return strings.TrimSpace(content) +} diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index d0ec758c6..62d6d95cb 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -37,3 +37,23 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return errors.New("feishu channel is not supported on 32-bit architectures") } + +// EditMessage is a stub method to satisfy MessageEditor +func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { + return nil +} + +// SendPlaceholder is a stub method to satisfy PlaceholderCapable +func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + return "", nil +} + +// ReactToMessage is a stub method to satisfy ReactionCapable +func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { + return func() {}, nil +} + +// SendMedia is a stub method to satisfy MediaSender +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return nil +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 1db1bf669..5f226e8f1 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -6,8 +6,11 @@ import ( "context" "encoding/json" "fmt" + "io" + "os" + "path/filepath" "sync" - "time" + "sync/atomic" lark "github.com/larksuite/oapi-sdk-go/v3" larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" @@ -19,14 +22,17 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) type FeishuChannel struct { *channels.BaseChannel - config config.FeishuConfig - client *lark.Client - wsClient *larkws.Client + feishuCfg config.FeishuConfig + client *lark.Client + wsClient *larkws.Client + + botOpenID atomic.Value // stores string; populated lazily for @mention detection mu sync.Mutex cancel context.CancelFunc @@ -38,19 +44,24 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) - return &FeishuChannel{ + ch := &FeishuChannel{ BaseChannel: base, - config: cfg, + feishuCfg: cfg, client: lark.NewClient(cfg.AppID, cfg.AppSecret), - }, nil + } + ch.SetOwner(ch) + return ch, nil } func (c *FeishuChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret == "" { + if c.feishuCfg.AppID == "" || c.feishuCfg.AppSecret == "" { return fmt.Errorf("feishu app_id or app_secret is empty") } - dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey). + // Fetch bot info to get the bot's open_id for mention detection + c.fetchBotOpenID(ctx) + + dispatcher := larkdispatcher.NewEventDispatcher(c.feishuCfg.VerificationToken, c.feishuCfg.EncryptKey). OnP2MessageReceiveV1(c.handleMessageReceive) runCtx, cancel := context.WithCancel(ctx) @@ -58,8 +69,8 @@ func (c *FeishuChannel) Start(ctx context.Context) error { c.mu.Lock() c.cancel = cancel c.wsClient = larkws.NewClient( - c.config.AppID, - c.config.AppSecret, + c.feishuCfg.AppID, + c.feishuCfg.AppSecret, larkws.WithEventHandler(dispatcher), ) wsClient := c.wsClient @@ -93,46 +104,211 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { return nil } +// Send sends a message using Interactive Card format for markdown rendering. +// Falls back to plain text if card building fails. func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty") + return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } - payload, err := json.Marshal(map[string]string{"text": msg.Content}) + // Build interactive card with markdown content + cardContent, err := buildMarkdownCard(msg.Content) if err != nil { - return fmt.Errorf("failed to marshal feishu content: %w", err) + return fmt.Errorf("feishu send: card build failed: %w", err) + } + return c.sendCard(ctx, msg.ChatID, cardContent) +} + +// EditMessage implements channels.MessageEditor. +// Uses Message.Patch to update an interactive card message. +func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { + cardContent, err := buildMarkdownCard(content) + if err != nil { + return fmt.Errorf("feishu edit: card build failed: %w", err) + } + + req := larkim.NewPatchMessageReqBuilder(). + MessageId(messageID). + Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()). + Build() + + resp, err := c.client.Im.V1.Message.Patch(ctx, req) + if err != nil { + return fmt.Errorf("feishu edit: %w", err) + } + if !resp.Success() { + return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + +// SendPlaceholder implements channels.PlaceholderCapable. +// Sends an interactive card with placeholder text and returns its message ID. +func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.feishuCfg.Placeholder.Enabled { + logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{ + "chat_id": chatID, + }) + return "", nil + } + + text := c.feishuCfg.Placeholder.Text + if text == "" { + text = "Thinking..." + } + + cardContent, err := buildMarkdownCard(text) + if err != nil { + return "", fmt.Errorf("feishu placeholder: card build failed: %w", err) } req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). - ReceiveId(msg.ChatID). - MsgType(larkim.MsgTypeText). - Content(string(payload)). - Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())). + ReceiveId(chatID). + MsgType(larkim.MsgTypeInteractive). + Content(cardContent). + Build()). Build() resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu placeholder send: %w", err) } - if !resp.Success() { - return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) } - logger.DebugCF("feishu", "Feishu message sent", map[string]any{ - "chat_id": msg.ChatID, - }) + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil +} + +// ReactToMessage implements channels.ReactionCapable. +// Adds an "Pin" reaction and returns an undo function to remove it. +func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { + req := larkim.NewCreateMessageReactionReqBuilder(). + MessageId(messageID). + Body(larkim.NewCreateMessageReactionReqBodyBuilder(). + ReactionType(larkim.NewEmojiBuilder().EmojiType("Pin").Build()). + Build()). + Build() + + resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req) + if err != nil { + logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{ + "message_id": messageID, + "error": err.Error(), + }) + return func() {}, fmt.Errorf("feishu react: %w", err) + } + if !resp.Success() { + logger.ErrorCF("feishu", "Reaction API error", map[string]any{ + "message_id": messageID, + "code": resp.Code, + "msg": resp.Msg, + }) + return func() {}, fmt.Errorf("feishu react api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + + var reactionID string + if resp.Data != nil && resp.Data.ReactionId != nil { + reactionID = *resp.Data.ReactionId + } + if reactionID == "" { + return func() {}, nil + } + + var undone atomic.Bool + undo := func() { + if !undone.CompareAndSwap(false, true) { + return + } + delReq := larkim.NewDeleteMessageReactionReqBuilder(). + MessageId(messageID). + ReactionId(reactionID). + Build() + _, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq) + } + return undo, nil +} + +// SendMedia implements channels.MediaSender. +// Uploads images/files via Feishu API then sends as messages. +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil { + return err + } + } return nil } +// sendMediaPart resolves and sends a single media part. +func (c *FeishuChannel) sendMediaPart( + ctx context.Context, + chatID string, + part bus.MediaPart, + store media.MediaStore, +) error { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + return nil // skip this part + } + + file, err := os.Open(localPath) + if err != nil { + logger.ErrorCF("feishu", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + return nil // skip this part + } + defer file.Close() + + switch part.Type { + case "image": + err = c.sendImage(ctx, chatID, file) + default: + filename := part.Filename + if filename == "" { + filename = "file" + } + err = c.sendFile(ctx, chatID, file, filename, part.Type) + } + + if err != nil { + logger.ErrorCF("feishu", "Failed to send media", map[string]any{ + "type": part.Type, + "error": err.Error(), + }) + return fmt.Errorf("feishu send media: %w", channels.ErrTemporary) + } + return nil +} + +// --- Inbound message handling --- + func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error { if event == nil || event.Event == nil || event.Event.Message == nil { return nil @@ -151,34 +327,57 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. senderID = "unknown" } - content := extractFeishuMessageContent(message) + messageType := stringValue(message.MessageType) + messageID := stringValue(message.MessageId) + rawContent := stringValue(message.Content) + + // Extract content based on message type + content := extractContent(messageType, rawContent) + + // Handle media messages (download and store) + var mediaRefs []string + if store := c.GetMediaStore(); store != nil && messageID != "" { + mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store) + } + + // Append media tags to content (like Telegram does) + content = appendMediaTags(content, messageType, mediaRefs) + if content == "" { content = "[empty message]" } metadata := map[string]string{} - messageID := "" - if mid := stringValue(message.MessageId); mid != "" { - messageID = mid + if messageID != "" { + metadata["message_id"] = messageID } - if messageType := stringValue(message.MessageType); messageType != "" { + if messageType != "" { metadata["message_type"] = messageType } - if chatType := stringValue(message.ChatType); chatType != "" { + chatType := stringValue(message.ChatType) + if chatType != "" { metadata["chat_type"] = chatType } if sender != nil && sender.TenantKey != nil { metadata["tenant_key"] = *sender.TenantKey } - chatType := stringValue(message.ChatType) var peer bus.Peer if chatType == "p2p" { peer = bus.Peer{Kind: "direct", ID: senderID} } else { peer = bus.Peer{Kind: "group", ID: chatID} + + // Check if bot was mentioned + isMentioned := c.isBotMentioned(message) + + // Strip mention placeholders from content before group trigger check + if len(message.Mentions) > 0 { + content = stripMentionPlaceholders(content, message.Mentions) + } + // In group chats, apply unified group trigger filtering - respond, cleaned := c.ShouldRespondInGroup(false, content) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil } @@ -186,9 +385,10 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. } logger.InfoCF("feishu", "Feishu message received", map[string]any{ - "sender_id": senderID, - "chat_id": chatID, - "preview": utils.Truncate(content, 80), + "sender_id": senderID, + "chat_id": chatID, + "message_id": messageID, + "preview": utils.Truncate(content, 80), }) senderInfo := bus.SenderInfo{ @@ -197,11 +397,373 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. CanonicalID: identity.BuildCanonicalID("feishu", senderID), } - if !c.IsAllowedSender(senderInfo) { - return nil + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) + return nil +} + +// --- Internal helpers --- + +// fetchBotOpenID attempts to detect the bot's open_id. +// The Lark v3 SDK doesn't expose a direct GetBotInfo method, +// so the open_id is populated lazily from the first @_user_1 mention event. +func (c *FeishuChannel) fetchBotOpenID(_ context.Context) { + logger.DebugC("feishu", "Bot open_id will be detected from first @_user_1 mention event") +} + +// isBotMentioned checks if the bot was @mentioned in the message. +func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool { + if message.Mentions == nil { + return false } - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo) + knownID, _ := c.botOpenID.Load().(string) + + for _, m := range message.Mentions { + if m.Id == nil { + continue + } + // If we already know the bot's open_id, match against it. + if m.Id.OpenId != nil && knownID != "" && *m.Id.OpenId == knownID { + return true + } + // If we don't know our bot open_id yet, use a reliable heuristic: + // Feishu assigns @_user_1 as the key for the first mention (the bot itself) + // when a user @mentions the bot. Only trust this specific key. + if knownID == "" && m.Key != nil && *m.Key == "@_user_1" && m.Id.OpenId != nil { + c.botOpenID.Store(*m.Id.OpenId) + logger.DebugCF("feishu", "Detected bot open_id from @_user_1 mention", map[string]any{ + "open_id": *m.Id.OpenId, + }) + return true + } + } + return false +} + +// extractContent extracts text content from different message types. +func extractContent(messageType, rawContent string) string { + if rawContent == "" { + return "" + } + + switch messageType { + case larkim.MsgTypeText: + var textPayload struct { + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(rawContent), &textPayload); err == nil { + return textPayload.Text + } + return rawContent + + case larkim.MsgTypePost: + // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text + return rawContent + + case larkim.MsgTypeImage: + // Image messages don't have text content + return "" + + case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: + // File/audio/video messages may have a filename + name := extractFileName(rawContent) + if name != "" { + return name + } + return "" + + default: + return rawContent + } +} + +// downloadInboundMedia downloads media from inbound messages and stores in MediaStore. +func (c *FeishuChannel) downloadInboundMedia( + ctx context.Context, + chatID, messageID, messageType, rawContent string, + store media.MediaStore, +) []string { + var refs []string + scope := channels.BuildMediaScope("feishu", chatID, messageID) + + switch messageType { + case larkim.MsgTypeImage: + imageKey := extractImageKey(rawContent) + if imageKey == "" { + return nil + } + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + + case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: + fileKey := extractFileKey(rawContent) + if fileKey == "" { + return nil + } + // Derive a fallback extension from the message type. + var ext string + switch messageType { + case larkim.MsgTypeAudio: + ext = ".ogg" + case larkim.MsgTypeMedia: + ext = ".mp4" + default: + ext = "" // generic file — rely on resp.FileName + } + ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + + return refs +} + +// downloadResource downloads a message resource (image/file) from Feishu, +// writes it to the project media directory, and stores the reference in MediaStore. +// fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension. +func (c *FeishuChannel) downloadResource( + ctx context.Context, + messageID, fileKey, resourceType, fallbackExt string, + store media.MediaStore, + scope string, +) string { + req := larkim.NewGetMessageResourceReqBuilder(). + MessageId(messageID). + FileKey(fileKey). + Type(resourceType). + Build() + + resp, err := c.client.Im.V1.MessageResource.Get(ctx, req) + if err != nil { + logger.ErrorCF("feishu", "Failed to download resource", map[string]any{ + "message_id": messageID, + "file_key": fileKey, + "error": err.Error(), + }) + return "" + } + if !resp.Success() { + logger.ErrorCF("feishu", "Resource download api error", map[string]any{ + "code": resp.Code, + "msg": resp.Msg, + }) + return "" + } + + if resp.File == nil { + return "" + } + // Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body). + if closer, ok := resp.File.(io.Closer); ok { + defer closer.Close() + } + + filename := resp.FileName + if filename == "" { + filename = fileKey + } + // If filename still has no extension, append the fallback (like Telegram's ext parameter). + if filepath.Ext(filename) == "" && fallbackExt != "" { + filename += fallbackExt + } + + // Write to the shared picoclaw_media directory using the original filename. + mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ + "error": err.Error(), + }) + return "" + } + localPath := filepath.Join(mediaDir, utils.SanitizeFilename(filename)) + + out, err := os.Create(localPath) + if err != nil { + logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{ + "error": err.Error(), + }) + return "" + } + defer out.Close() + + if _, err := io.Copy(out, resp.File); err != nil { + out.Close() + os.Remove(localPath) + logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ + "error": err.Error(), + }) + return "" + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "feishu", + }, scope) + if err != nil { + logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{ + "file_key": fileKey, + "error": err.Error(), + }) + os.Remove(localPath) + return "" + } + + return ref +} + +// appendMediaTags appends media type tags to content (like Telegram's "[image: photo]"). +func appendMediaTags(content, messageType string, mediaRefs []string) string { + if len(mediaRefs) == 0 { + return content + } + + var tag string + switch messageType { + case larkim.MsgTypeImage: + tag = "[image: photo]" + case larkim.MsgTypeAudio: + tag = "[audio]" + case larkim.MsgTypeMedia: + tag = "[video]" + case larkim.MsgTypeFile: + tag = "[file]" + default: + tag = "[attachment]" + } + + if content == "" { + return tag + } + return content + " " + tag +} + +// sendCard sends an interactive card message to a chat. +func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeInteractive). + Content(cardContent). + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) + } + + if !resp.Success() { + return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + } + + logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ + "chat_id": chatID, + }) + + return nil +} + +// sendImage uploads an image and sends it as a message. +func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error { + // Upload image to get image_key + uploadReq := larkim.NewCreateImageReqBuilder(). + Body(larkim.NewCreateImageReqBodyBuilder(). + ImageType("message"). + Image(file). + Build()). + Build() + + uploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq) + if err != nil { + return fmt.Errorf("feishu image upload: %w", err) + } + if !uploadResp.Success() { + return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) + } + if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { + return fmt.Errorf("feishu image upload: no image_key returned") + } + + imageKey := *uploadResp.Data.ImageKey + + // Send image message + content, _ := json.Marshal(map[string]string{"image_key": imageKey}) + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeImage). + Content(string(content)). + + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return fmt.Errorf("feishu image send: %w", err) + } + if !resp.Success() { + return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + +// sendFile uploads a file and sends it as a message. +func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error { + // Map part type to Feishu file type + feishuFileType := "stream" + switch fileType { + case "audio": + feishuFileType = "opus" + case "video": + feishuFileType = "mp4" + } + + // Upload file to get file_key + uploadReq := larkim.NewCreateFileReqBuilder(). + Body(larkim.NewCreateFileReqBodyBuilder(). + FileType(feishuFileType). + FileName(filename). + File(file). + Build()). + Build() + + uploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq) + if err != nil { + return fmt.Errorf("feishu file upload: %w", err) + } + if !uploadResp.Success() { + return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) + } + if uploadResp.Data == nil || uploadResp.Data.FileKey == nil { + return fmt.Errorf("feishu file upload: no file_key returned") + } + + fileKey := *uploadResp.Data.FileKey + + // Send file message + content, _ := json.Marshal(map[string]string{"file_key": fileKey}) + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeFile). + Content(string(content)). + + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return fmt.Errorf("feishu file send: %w", err) + } + if !resp.Success() { + return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg) + } return nil } @@ -222,20 +784,3 @@ func extractFeishuSenderID(sender *larkim.EventSender) string { return "" } - -func extractFeishuMessageContent(message *larkim.EventMessage) string { - if message == nil || message.Content == nil || *message.Content == "" { - return "" - } - - if message.MessageType != nil && *message.MessageType == larkim.MsgTypeText { - var textPayload struct { - Text string `json:"text"` - } - if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil { - return textPayload.Text - } - } - - return *message.Content -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 9f4769de4..b4138b590 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -252,6 +252,7 @@ type FeishuConfig struct { VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` } From 0bee9d7bcf8839bedbd842913c342e65b2367c80 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Tue, 3 Mar 2026 01:04:06 +0800 Subject: [PATCH 64/82] fix(feishu): resolve lint issues --- pkg/channels/feishu/feishu_64.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5f226e8f1..24ab4fa85 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -172,7 +172,6 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str ReceiveId(chatID). MsgType(larkim.MsgTypeInteractive). Content(cardContent). - Build()). Build() @@ -572,9 +571,9 @@ func (c *FeishuChannel) downloadResource( // Write to the shared picoclaw_media directory using the original filename. mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") - if err := os.MkdirAll(mediaDir, 0o700); err != nil { + if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ - "error": err.Error(), + "error": mkdirErr.Error(), }) return "" } @@ -589,11 +588,11 @@ func (c *FeishuChannel) downloadResource( } defer out.Close() - if _, err := io.Copy(out, resp.File); err != nil { + if _, copyErr := io.Copy(out, resp.File); copyErr != nil { out.Close() os.Remove(localPath) logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ - "error": err.Error(), + "error": copyErr.Error(), }) return "" } @@ -698,7 +697,6 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F ReceiveId(chatID). MsgType(larkim.MsgTypeImage). Content(string(content)). - Build()). Build() @@ -753,7 +751,6 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi ReceiveId(chatID). MsgType(larkim.MsgTypeFile). Content(string(content)). - Build()). Build() From 42eb6ea410569b7a003f240adcd55d519406445d Mon Sep 17 00:00:00 2001 From: Hoshina Date: Tue, 3 Mar 2026 01:27:39 +0800 Subject: [PATCH 65/82] fix(feishu): address review findings - Remove stale "falls back to plain text" comment on Send - Add empty ChatID validation in SendMedia to match Send - Use messageID+fileKey as local filename to avoid write collisions - Check allowlist before downloading inbound media to avoid wasted I/O - Return errUnsupported consistently from all 32-bit stub methods --- pkg/channels/feishu/feishu_32.go | 16 +++++++++------- pkg/channels/feishu/feishu_64.go | 27 ++++++++++++++++++--------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index 62d6d95cb..f5e3aa224 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -16,6 +16,8 @@ type FeishuChannel struct { *channels.BaseChannel } +var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures") + // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { return nil, errors.New( @@ -25,35 +27,35 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan // Start is a stub method to satisfy the Channel interface func (c *FeishuChannel) Start(ctx context.Context) error { - return nil + return errUnsupported } // Stop is a stub method to satisfy the Channel interface func (c *FeishuChannel) Stop(ctx context.Context) error { - return nil + return errUnsupported } // Send is a stub method to satisfy the Channel interface func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return errors.New("feishu channel is not supported on 32-bit architectures") + return errUnsupported } // EditMessage is a stub method to satisfy MessageEditor func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { - return nil + return errUnsupported } // SendPlaceholder is a stub method to satisfy PlaceholderCapable func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - return "", nil + return "", errUnsupported } // ReactToMessage is a stub method to satisfy ReactionCapable func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { - return func() {}, nil + return func() {}, errUnsupported } // SendMedia is a stub method to satisfy MediaSender func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { - return nil + return errUnsupported } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 24ab4fa85..7aa24588a 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -105,7 +105,6 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send sends a message using Interactive Card format for markdown rendering. -// Falls back to plain text if card building fails. func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -245,6 +244,10 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess return channels.ErrNotRunning } + if msg.ChatID == "" { + return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + } + store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) @@ -330,6 +333,17 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. messageID := stringValue(message.MessageId) rawContent := stringValue(message.Content) + // Check allowlist early to avoid downloading media for rejected senders. + // BaseChannel.HandleMessage will check again, but this avoids wasted network I/O. + senderInfo := bus.SenderInfo{ + Platform: "feishu", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("feishu", senderID), + } + if !c.IsAllowedSender(senderInfo) { + return nil + } + // Extract content based on message type content := extractContent(messageType, rawContent) @@ -390,12 +404,6 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. "preview": utils.Truncate(content, 80), }) - senderInfo := bus.SenderInfo{ - Platform: "feishu", - PlatformID: senderID, - CanonicalID: identity.BuildCanonicalID("feishu", senderID), - } - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) return nil } @@ -569,7 +577,7 @@ func (c *FeishuChannel) downloadResource( filename += fallbackExt } - // Write to the shared picoclaw_media directory using the original filename. + // Write to the shared picoclaw_media directory using a unique name to avoid collisions. mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ @@ -577,7 +585,8 @@ func (c *FeishuChannel) downloadResource( }) return "" } - localPath := filepath.Join(mediaDir, utils.SanitizeFilename(filename)) + ext := filepath.Ext(filename) + localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext)) out, err := os.Create(localPath) if err != nil { From 595de7814d617687923671684f8b670a86c7d758 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Tue, 3 Mar 2026 01:38:52 +0800 Subject: [PATCH 66/82] fix(feishu): remove dead fetchBotOpenID stub and fix misleading comment --- pkg/channels/feishu/feishu_64.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 7aa24588a..f8b779e71 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -58,8 +58,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { return fmt.Errorf("feishu app_id or app_secret is empty") } - // Fetch bot info to get the bot's open_id for mention detection - c.fetchBotOpenID(ctx) + // Bot open_id for @mention detection is populated lazily from the first mention event. dispatcher := larkdispatcher.NewEventDispatcher(c.feishuCfg.VerificationToken, c.feishuCfg.EncryptKey). OnP2MessageReceiveV1(c.handleMessageReceive) @@ -410,13 +409,6 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. // --- Internal helpers --- -// fetchBotOpenID attempts to detect the bot's open_id. -// The Lark v3 SDK doesn't expose a direct GetBotInfo method, -// so the open_id is populated lazily from the first @_user_1 mention event. -func (c *FeishuChannel) fetchBotOpenID(_ context.Context) { - logger.DebugC("feishu", "Bot open_id will be detected from first @_user_1 mention event") -} - // isBotMentioned checks if the bot was @mentioned in the message. func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool { if message.Mentions == nil { From 23bb0828b18eb343a99f9c94bdc3da648da76d3c Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Mon, 2 Mar 2026 19:50:34 +0100 Subject: [PATCH 67/82] mcp http server example --- config/config.example.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/config.example.json b/config/config.example.json index 0c4991a49..fe3740289 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -246,6 +246,14 @@ "mcp": { "enabled": false, "servers": { + "context7": { + "enabled": false, + "type": "http", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "ctx7sk-xx" + } + }, "filesystem": { "enabled": false, "command": "npx", From 946af6b53db371a1dba5a09ae52ca3c034d65086 Mon Sep 17 00:00:00 2001 From: Alfonso Date: Mon, 2 Mar 2026 13:23:55 -0800 Subject: [PATCH 68/82] feat: add LiteLLM provider alias support (#930) --- README.md | 16 ++++++++- pkg/config/config.go | 2 ++ pkg/config/migration.go | 17 ++++++++++ pkg/config/migration_test.go | 34 ++++++++++++++++++-- pkg/providers/factory.go | 9 ++++++ pkg/providers/factory_provider.go | 6 ++-- pkg/providers/factory_provider_test.go | 26 +++++++++++++++ pkg/providers/factory_test.go | 21 ++++++++++++ pkg/providers/openai_compat/provider.go | 2 +- pkg/providers/openai_compat/provider_test.go | 5 +++ 10 files changed, 131 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a06f2ea61..6714ac6eb 100644 --- a/README.md +++ b/README.md @@ -925,7 +925,7 @@ This design also enables **multi-agent support** with flexible provider selectio #### 📋 All Supported Vendors | Vendor | `model` Prefix | Default API Base | Protocol | API Key | -| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | @@ -937,6 +937,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | @@ -1038,6 +1039,19 @@ This design also enables **multi-agent support** with flexible provider selectio } ``` +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_key": "sk-..." +} +``` + +PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`. + #### Load Balancing Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: diff --git a/pkg/config/config.go b/pkg/config/config.go index c84cd90cf..305ae67e3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -399,6 +399,7 @@ type DevicesConfig struct { type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI OpenAIProviderConfig `json:"openai"` + LiteLLM ProviderConfig `json:"litellm"` OpenRouter ProviderConfig `json:"openrouter"` Groq ProviderConfig `json:"groq"` Zhipu ProviderConfig `json:"zhipu"` @@ -422,6 +423,7 @@ type ProvidersConfig struct { func (p ProvidersConfig) IsEmpty() bool { return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" && p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" && + p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" && p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" && p.Groq.APIKey == "" && p.Groq.APIBase == "" && p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" && diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 5deb09270..772f714fd 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -88,6 +88,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { }, true }, }, + { + providerNames: []string{"litellm"}, + protocol: "litellm", + buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" { + return ModelConfig{}, false + } + return ModelConfig{ + ModelName: "litellm", + Model: "litellm/auto", + APIKey: p.LiteLLM.APIKey, + APIBase: p.LiteLLM.APIBase, + Proxy: p.LiteLLM.Proxy, + RequestTimeout: p.LiteLLM.RequestTimeout, + }, true + }, + }, { providerNames: []string{"openrouter"}, protocol: "openrouter", diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index db8f4657d..e24e9fa1d 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -63,6 +63,33 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) { } } +func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { + cfg := &Config{ + Providers: ProvidersConfig{ + LiteLLM: ProviderConfig{ + APIKey: "litellm-key", + APIBase: "http://localhost:4000/v1", + }, + }, + } + + result := ConvertProvidersToModelList(cfg) + + if len(result) != 1 { + t.Fatalf("len(result) = %d, want 1", len(result)) + } + + if result[0].ModelName != "litellm" { + t.Errorf("ModelName = %q, want %q", result[0].ModelName, "litellm") + } + if result[0].Model != "litellm/auto" { + t.Errorf("Model = %q, want %q", result[0].Model, "litellm/auto") + } + if result[0].APIBase != "http://localhost:4000/v1" { + t.Errorf("APIBase = %q, want %q", result[0].APIBase, "http://localhost:4000/v1") + } +} + func TestConvertProvidersToModelList_Multiple(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ @@ -115,6 +142,7 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}}, + LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, Anthropic: ProviderConfig{APIKey: "key2"}, OpenRouter: ProviderConfig{APIKey: "key3"}, Groq: ProviderConfig{APIKey: "key4"}, @@ -137,9 +165,9 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { result := ConvertProvidersToModelList(cfg) - // All 18 providers should be converted - if len(result) != 18 { - t.Errorf("len(result) = %d, want 18", len(result)) + // All 19 providers should be converted + if len(result) != 19 { + t.Errorf("len(result) = %d, want 19", len(result)) } } diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index 11af14da4..5b3e42b9e 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -102,6 +102,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { sel.apiBase = "https://openrouter.ai/api/v1" } } + case "litellm": + if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" { + sel.apiKey = cfg.Providers.LiteLLM.APIKey + sel.apiBase = cfg.Providers.LiteLLM.APIBase + sel.proxy = cfg.Providers.LiteLLM.Proxy + if sel.apiBase == "" { + sel.apiBase = "http://localhost:4000/v1" + } + } case "zhipu", "glm": if cfg.Providers.Zhipu.APIKey != "" { sel.apiKey = cfg.Providers.Zhipu.APIKey diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 53f7a08a0..155317a3b 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -53,7 +53,7 @@ func ExtractProtocol(model string) (protocol, modelID string) { // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. -// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot +// Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot // Returns the provider, the model ID (without protocol prefix), and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { @@ -92,7 +92,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, nil - case "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "volcengine", "vllm", "qwen", "mistral": // All other OpenAI-compatible HTTP providers @@ -180,6 +180,8 @@ func getDefaultAPIBase(protocol string) string { return "https://api.openai.com/v1" case "openrouter": return "https://openrouter.ai/api/v1" + case "litellm": + return "http://localhost:4000/v1" case "groq": return "https://api.groq.com/openai/v1" case "zhipu": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index e0c0eddef..78389f331 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -135,6 +135,32 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { } } +func TestGetDefaultAPIBase_LiteLLM(t *testing.T) { + if got := getDefaultAPIBase("litellm"); got != "http://localhost:4000/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "litellm", got, "http://localhost:4000/v1") + } +} + +func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-litellm", + Model: "litellm/my-proxy-alias", + APIKey: "test-key", + APIBase: "http://localhost:4000/v1", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "my-proxy-alias" { + t.Errorf("modelID = %q, want %q", modelID, "my-proxy-alias") + } +} + func TestCreateProviderFromConfig_Anthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-anthropic", diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go index 5680f23b3..f7a916d9e 100644 --- a/pkg/providers/factory_test.go +++ b/pkg/providers/factory_test.go @@ -17,6 +17,27 @@ func TestResolveProviderSelection(t *testing.T) { wantProxy string wantErrSubstr string }{ + { + name: "explicit litellm provider uses configured base", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "litellm" + cfg.Providers.LiteLLM.APIKey = "litellm-key" + cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1" + cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "http://localhost:4000/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "explicit litellm provider defaults base when only key is configured", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "litellm" + cfg.Providers.LiteLLM.APIKey = "litellm-key" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "http://localhost:4000/v1", + }, { name: "explicit claude-cli provider routes to cli provider type", setup: func(cfg *config.Config) { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 74e612046..3a18b8b16 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -325,7 +325,7 @@ func normalizeModel(model, apiBase string) string { prefix := strings.ToLower(before) switch prefix { - case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral": + case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral": return after default: return model diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index d9e6ba871..53b9e75ee 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -256,6 +256,11 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { input string wantModel string }{ + { + name: "strips litellm prefix and preserves proxy model name", + input: "litellm/my-proxy-alias", + wantModel: "my-proxy-alias", + }, { name: "strips groq prefix and keeps nested model", input: "groq/openai/gpt-oss-120b", From 8ebeefc59f6f43954b9f4fd57b7359176aa9f715 Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 11:13:22 +0800 Subject: [PATCH 69/82] fix(agent,openai_compat): address review feedback on vision pipeline - serializeMessages: preserve ToolCallID/ToolCalls when Media is present - resolveMediaRefs: add 20MB file size limit to prevent OOM - mimeFromExtension: return empty string for unknown extensions - Add 11 unit tests for serializeMessages, resolveMediaRefs, mimeFromExtension Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 31 ++++- pkg/agent/loop_test.go | 123 +++++++++++++++++++ pkg/providers/openai_compat/provider.go | 6 + pkg/providers/openai_compat/provider_test.go | 70 +++++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 36a3ad508..11d4fa7db 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1356,6 +1356,10 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { return &routing.RoutePeer{Kind: parentKind, ID: parentID} } +// maxMediaFileSize is the maximum file size (20 MB) for media resolution. +// Files larger than this are skipped to prevent OOM under concurrent load. +const maxMediaFileSize = 20 * 1024 * 1024 + // resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs. // Returns a new slice with resolved URLs; original messages are not mutated. func resolveMediaRefs(messages []providers.Message, store media.MediaStore) []providers.Message { @@ -1387,6 +1391,23 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore) []pr continue } + info, err := os.Stat(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to stat media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + if info.Size() > maxMediaFileSize { + logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ + "path": localPath, + "size": info.Size(), + "max_size": maxMediaFileSize, + }) + continue + } + data, err := os.ReadFile(localPath) if err != nil { logger.WarnCF("agent", "Failed to read media file", map[string]any{ @@ -1400,6 +1421,13 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore) []pr if mime == "" { mime = mimeFromExtension(filepath.Ext(localPath)) } + if mime == "" { + logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{ + "path": localPath, + "ext": filepath.Ext(localPath), + }) + continue + } dataURL := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) resolved = append(resolved, dataURL) @@ -1412,6 +1440,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore) []pr } // mimeFromExtension returns a MIME type for common image extensions. +// Returns empty string for unrecognized extensions. func mimeFromExtension(ext string) string { switch strings.ToLower(ext) { case ".jpg", ".jpeg": @@ -1425,6 +1454,6 @@ func mimeFromExtension(ext string) string { case ".bmp": return "image/bmp" default: - return "image/jpeg" + return "" } } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 1034b06e8..c4f139630 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -6,12 +6,14 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -840,3 +842,124 @@ func TestHandleReasoning(t *testing.T) { } }) } + +func TestMimeFromExtension(t *testing.T) { + tests := []struct { + ext string + want string + }{ + {".jpg", "image/jpeg"}, + {".JPEG", "image/jpeg"}, + {".png", "image/png"}, + {".gif", "image/gif"}, + {".webp", "image/webp"}, + {".bmp", "image/bmp"}, + {".txt", ""}, + {".pdf", ""}, + {"", ""}, + } + for _, tt := range tests { + if got := mimeFromExtension(tt.ext); got != tt.want { + t.Errorf("mimeFromExtension(%q) = %q, want %q", tt.ext, got, tt.want) + } + } +} + +func TestResolveMediaRefs_NilStore(t *testing.T) { + msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{"media://abc"}}} + result := resolveMediaRefs(msgs, nil) + if result[0].Media[0] != "media://abc" { + t.Error("nil store should return messages unchanged") + } +} + +func TestResolveMediaRefs_NonMediaRef(t *testing.T) { + msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}}} + result := resolveMediaRefs(msgs, media.NewFileMediaStore()) + if result[0].Media[0] != "https://example.com/img.png" { + t.Error("non-media:// refs should be passed through unchanged") + } +} + +func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { + store := media.NewFileMediaStore() + + imgPath := filepath.Join(t.TempDir(), "test.png") + if err := os.WriteFile(imgPath, []byte("fake-png-data"), 0o644); err != nil { + t.Fatal(err) + } + + ref, err := store.Store(imgPath, media.MediaMeta{ContentType: "image/png"}, "test") + if err != nil { + t.Fatal(err) + } + + msgs := []providers.Message{{Role: "user", Content: "describe", Media: []string{ref}}} + result := resolveMediaRefs(msgs, store) + + if len(result[0].Media) != 1 { + t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) + } + if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { + t.Errorf("expected data URL, got %s", result[0].Media[0][:40]) + } +} + +func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { + store := media.NewFileMediaStore() + + bigPath := filepath.Join(t.TempDir(), "big.jpg") + if err := os.WriteFile(bigPath, make([]byte, maxMediaFileSize+1), 0o644); err != nil { + t.Fatal(err) + } + + ref, err := store.Store(bigPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") + if err != nil { + t.Fatal(err) + } + + msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{ref}}} + result := resolveMediaRefs(msgs, store) + + if len(result[0].Media) != 0 { + t.Error("oversized file should be skipped") + } +} + +func TestResolveMediaRefs_SkipsUnknownExtension(t *testing.T) { + store := media.NewFileMediaStore() + + txtPath := filepath.Join(t.TempDir(), "readme.txt") + if err := os.WriteFile(txtPath, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + ref, err := store.Store(txtPath, media.MediaMeta{}, "test") + if err != nil { + t.Fatal(err) + } + + msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{ref}}} + result := resolveMediaRefs(msgs, store) + + if len(result[0].Media) != 0 { + t.Error("unknown extension with no ContentType should be skipped") + } +} + +func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { + store := media.NewFileMediaStore() + + imgPath := filepath.Join(t.TempDir(), "test.jpg") + if err := os.WriteFile(imgPath, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + + ref, _ := store.Store(imgPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") + original := []providers.Message{{Role: "user", Content: "hi", Media: []string{ref}}} + resolveMediaRefs(original, store) + + if !strings.HasPrefix(original[0].Media[0], "media://") { + t.Error("original message should not be mutated") + } +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index f6c5a3664..770881b49 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -235,6 +235,12 @@ func serializeMessages(messages []Message) []map[string]interface{} { "role": m.Role, "content": parts, } + if m.ToolCallID != "" { + msg["tool_call_id"] = m.ToolCallID + } + if len(m.ToolCalls) > 0 { + msg["tool_calls"] = m.ToolCalls + } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index d9e6ba871..da3e48cf0 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -411,3 +411,73 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } + +func TestSerializeMessages_PlainText(t *testing.T) { + msgs := []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi"}, + } + result := serializeMessages(msgs) + if len(result) != 2 { + t.Fatalf("expected 2 messages, got %d", len(result)) + } + if result[0]["content"] != "hello" { + t.Errorf("expected plain string content, got %v", result[0]["content"]) + } +} + +func TestSerializeMessages_WithMedia(t *testing.T) { + msgs := []Message{ + {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, + } + result := serializeMessages(msgs) + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + parts, ok := result[0]["content"].([]map[string]interface{}) + if !ok { + t.Fatalf("expected content to be []map, got %T", result[0]["content"]) + } + if len(parts) != 2 { + t.Fatalf("expected 2 parts (text + image), got %d", len(parts)) + } + if parts[0]["type"] != "text" { + t.Errorf("expected first part type=text, got %v", parts[0]["type"]) + } + if parts[1]["type"] != "image_url" { + t.Errorf("expected second part type=image_url, got %v", parts[1]["type"]) + } +} + +func TestSerializeMessages_WithMediaPreservesToolFields(t *testing.T) { + msgs := []Message{ + { + Role: "assistant", + Content: "result", + Media: []string{"data:image/png;base64,abc"}, + ToolCallID: "call_123", + ToolCalls: []ToolCall{{ID: "tc_1", Type: "function", Function: &FunctionCall{Name: "test", Arguments: "{}"}}}, + ReasoningContent: "thinking...", + }, + } + result := serializeMessages(msgs) + if result[0]["tool_call_id"] != "call_123" { + t.Errorf("expected tool_call_id=call_123, got %v", result[0]["tool_call_id"]) + } + if result[0]["tool_calls"] == nil { + t.Error("expected tool_calls to be present") + } + if result[0]["reasoning_content"] != "thinking..." { + t.Errorf("expected reasoning_content, got %v", result[0]["reasoning_content"]) + } +} + +func TestSerializeMessages_EmptyMediaUsesPlainFormat(t *testing.T) { + msgs := []Message{ + {Role: "user", Content: "hello", Media: []string{}}, + } + result := serializeMessages(msgs) + if _, ok := result[0]["content"].(string); !ok { + t.Errorf("empty Media should use plain string format, got %T", result[0]["content"]) + } +} From 407707a7cccaf555fdc3d54b26c5f4a46467a41a Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:38:32 +0800 Subject: [PATCH 70/82] Revert "feat(agent): add vision/image support to agent pipeline" --- pkg/agent/context.go | 3 +- pkg/agent/loop.go | 119 +----------------- pkg/agent/loop_test.go | 123 ------------------- pkg/providers/openai_compat/provider.go | 56 +-------- pkg/providers/openai_compat/provider_test.go | 70 ----------- pkg/providers/protocoltypes/types.go | 1 - 6 files changed, 8 insertions(+), 364 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 8868d6bf4..6fccbaf53 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -465,11 +465,10 @@ func (cb *ContextBuilder) BuildMessages( messages = append(messages, history...) // Add current user message - if strings.TrimSpace(currentMessage) != "" || len(media) > 0 { + if strings.TrimSpace(currentMessage) != "" { messages = append(messages, providers.Message{ Role: "user", Content: currentMessage, - Media: media, }) } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 29c39d396..88afa6119 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -8,11 +8,9 @@ package agent import ( "context" - "encoding/base64" "encoding/json" "errors" "fmt" - "os" "path/filepath" "strings" "sync" @@ -49,12 +47,11 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - UserMessage string // User message content (may include prefix) - Media []string // Media URLs attached to the user message - DefaultResponse string // Response when LLM returns empty + SessionKey string // Session identifier for history/context + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + UserMessage string // User message content (may include prefix) + DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus NoHistory bool // If true, don't load session history (for heartbeat) @@ -499,7 +496,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, - Media: msg.Media, DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, @@ -606,11 +602,10 @@ func (al *AgentLoop) runAgentLoop( history, summary, opts.UserMessage, - opts.Media, + nil, opts.Channel, opts.ChatID, ) - messages = resolveMediaRefs(messages, al.mediaStore) // 3. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) @@ -1481,105 +1476,3 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { } return &routing.RoutePeer{Kind: parentKind, ID: parentID} } - -// maxMediaFileSize is the maximum file size (20 MB) for media resolution. -// Files larger than this are skipped to prevent OOM under concurrent load. -const maxMediaFileSize = 20 * 1024 * 1024 - -// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs. -// Returns a new slice with resolved URLs; original messages are not mutated. -func resolveMediaRefs(messages []providers.Message, store media.MediaStore) []providers.Message { - if store == nil { - return messages - } - - result := make([]providers.Message, len(messages)) - copy(result, messages) - - for i, m := range result { - if len(m.Media) == 0 { - continue - } - - resolved := make([]string, 0, len(m.Media)) - for _, ref := range m.Media { - if !strings.HasPrefix(ref, "media://") { - resolved = append(resolved, ref) - continue - } - - localPath, meta, err := store.ResolveWithMeta(ref) - if err != nil { - logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ - "ref": ref, - "error": err.Error(), - }) - continue - } - - info, err := os.Stat(localPath) - if err != nil { - logger.WarnCF("agent", "Failed to stat media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - continue - } - if info.Size() > maxMediaFileSize { - logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ - "path": localPath, - "size": info.Size(), - "max_size": maxMediaFileSize, - }) - continue - } - - data, err := os.ReadFile(localPath) - if err != nil { - logger.WarnCF("agent", "Failed to read media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - continue - } - - mime := meta.ContentType - if mime == "" { - mime = mimeFromExtension(filepath.Ext(localPath)) - } - if mime == "" { - logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{ - "path": localPath, - "ext": filepath.Ext(localPath), - }) - continue - } - - dataURL := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) - resolved = append(resolved, dataURL) - } - - result[i].Media = resolved - } - - return result -} - -// mimeFromExtension returns a MIME type for common image extensions. -// Returns empty string for unrecognized extensions. -func mimeFromExtension(ext string) string { - switch strings.ToLower(ext) { - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - case ".gif": - return "image/gif" - case ".webp": - return "image/webp" - case ".bmp": - return "image/bmp" - default: - return "" - } -} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4c803fea6..3565314fe 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -6,14 +6,12 @@ import ( "os" "path/filepath" "slices" - "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -810,124 +808,3 @@ func TestHandleReasoning(t *testing.T) { } }) } - -func TestMimeFromExtension(t *testing.T) { - tests := []struct { - ext string - want string - }{ - {".jpg", "image/jpeg"}, - {".JPEG", "image/jpeg"}, - {".png", "image/png"}, - {".gif", "image/gif"}, - {".webp", "image/webp"}, - {".bmp", "image/bmp"}, - {".txt", ""}, - {".pdf", ""}, - {"", ""}, - } - for _, tt := range tests { - if got := mimeFromExtension(tt.ext); got != tt.want { - t.Errorf("mimeFromExtension(%q) = %q, want %q", tt.ext, got, tt.want) - } - } -} - -func TestResolveMediaRefs_NilStore(t *testing.T) { - msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{"media://abc"}}} - result := resolveMediaRefs(msgs, nil) - if result[0].Media[0] != "media://abc" { - t.Error("nil store should return messages unchanged") - } -} - -func TestResolveMediaRefs_NonMediaRef(t *testing.T) { - msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}}} - result := resolveMediaRefs(msgs, media.NewFileMediaStore()) - if result[0].Media[0] != "https://example.com/img.png" { - t.Error("non-media:// refs should be passed through unchanged") - } -} - -func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { - store := media.NewFileMediaStore() - - imgPath := filepath.Join(t.TempDir(), "test.png") - if err := os.WriteFile(imgPath, []byte("fake-png-data"), 0o644); err != nil { - t.Fatal(err) - } - - ref, err := store.Store(imgPath, media.MediaMeta{ContentType: "image/png"}, "test") - if err != nil { - t.Fatal(err) - } - - msgs := []providers.Message{{Role: "user", Content: "describe", Media: []string{ref}}} - result := resolveMediaRefs(msgs, store) - - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) - } - if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { - t.Errorf("expected data URL, got %s", result[0].Media[0][:40]) - } -} - -func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { - store := media.NewFileMediaStore() - - bigPath := filepath.Join(t.TempDir(), "big.jpg") - if err := os.WriteFile(bigPath, make([]byte, maxMediaFileSize+1), 0o644); err != nil { - t.Fatal(err) - } - - ref, err := store.Store(bigPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") - if err != nil { - t.Fatal(err) - } - - msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{ref}}} - result := resolveMediaRefs(msgs, store) - - if len(result[0].Media) != 0 { - t.Error("oversized file should be skipped") - } -} - -func TestResolveMediaRefs_SkipsUnknownExtension(t *testing.T) { - store := media.NewFileMediaStore() - - txtPath := filepath.Join(t.TempDir(), "readme.txt") - if err := os.WriteFile(txtPath, []byte("hello"), 0o644); err != nil { - t.Fatal(err) - } - - ref, err := store.Store(txtPath, media.MediaMeta{}, "test") - if err != nil { - t.Fatal(err) - } - - msgs := []providers.Message{{Role: "user", Content: "hi", Media: []string{ref}}} - result := resolveMediaRefs(msgs, store) - - if len(result[0].Media) != 0 { - t.Error("unknown extension with no ContentType should be skipped") - } -} - -func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { - store := media.NewFileMediaStore() - - imgPath := filepath.Join(t.TempDir(), "test.jpg") - if err := os.WriteFile(imgPath, []byte("data"), 0o644); err != nil { - t.Fatal(err) - } - - ref, _ := store.Store(imgPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") - original := []providers.Message{{Role: "user", Content: "hi", Media: []string{ref}}} - resolveMediaRefs(original, store) - - if !strings.HasPrefix(original[0].Media[0], "media://") { - t.Error("original message should not be mutated") - } -} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index e3cd1560e..3a18b8b16 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -116,7 +116,7 @@ func (p *Provider) Chat( requestBody := map[string]any{ "model": model, - "messages": serializeMessages(messages), + "messages": stripSystemParts(messages), } if len(tools) > 0 { @@ -195,60 +195,6 @@ func (p *Provider) Chat( return parseResponse(body) } -func serializeMessages(messages []Message) []map[string]interface{} { - result := make([]map[string]interface{}, 0, len(messages)) - for _, m := range messages { - if len(m.Media) == 0 { - msg := map[string]interface{}{ - "role": m.Role, - "content": m.Content, - } - if m.ToolCallID != "" { - msg["tool_call_id"] = m.ToolCallID - } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls - } - if m.ReasoningContent != "" { - msg["reasoning_content"] = m.ReasoningContent - } - result = append(result, msg) - continue - } - - parts := make([]map[string]interface{}, 0, 1+len(m.Media)) - if m.Content != "" { - parts = append(parts, map[string]interface{}{ - "type": "text", - "text": m.Content, - }) - } - for _, mediaURL := range m.Media { - parts = append(parts, map[string]interface{}{ - "type": "image_url", - "image_url": map[string]interface{}{ - "url": mediaURL, - }, - }) - } - msg := map[string]interface{}{ - "role": m.Role, - "content": parts, - } - if m.ToolCallID != "" { - msg["tool_call_id"] = m.ToolCallID - } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls - } - if m.ReasoningContent != "" { - msg["reasoning_content"] = m.ReasoningContent - } - result = append(result, msg) - } - return result -} - func parseResponse(body []byte) (*LLMResponse, error) { var apiResponse struct { Choices []struct { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 0fd912541..53b9e75ee 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -416,73 +416,3 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } - -func TestSerializeMessages_PlainText(t *testing.T) { - msgs := []Message{ - {Role: "user", Content: "hello"}, - {Role: "assistant", Content: "hi"}, - } - result := serializeMessages(msgs) - if len(result) != 2 { - t.Fatalf("expected 2 messages, got %d", len(result)) - } - if result[0]["content"] != "hello" { - t.Errorf("expected plain string content, got %v", result[0]["content"]) - } -} - -func TestSerializeMessages_WithMedia(t *testing.T) { - msgs := []Message{ - {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, - } - result := serializeMessages(msgs) - if len(result) != 1 { - t.Fatalf("expected 1 message, got %d", len(result)) - } - parts, ok := result[0]["content"].([]map[string]interface{}) - if !ok { - t.Fatalf("expected content to be []map, got %T", result[0]["content"]) - } - if len(parts) != 2 { - t.Fatalf("expected 2 parts (text + image), got %d", len(parts)) - } - if parts[0]["type"] != "text" { - t.Errorf("expected first part type=text, got %v", parts[0]["type"]) - } - if parts[1]["type"] != "image_url" { - t.Errorf("expected second part type=image_url, got %v", parts[1]["type"]) - } -} - -func TestSerializeMessages_WithMediaPreservesToolFields(t *testing.T) { - msgs := []Message{ - { - Role: "assistant", - Content: "result", - Media: []string{"data:image/png;base64,abc"}, - ToolCallID: "call_123", - ToolCalls: []ToolCall{{ID: "tc_1", Type: "function", Function: &FunctionCall{Name: "test", Arguments: "{}"}}}, - ReasoningContent: "thinking...", - }, - } - result := serializeMessages(msgs) - if result[0]["tool_call_id"] != "call_123" { - t.Errorf("expected tool_call_id=call_123, got %v", result[0]["tool_call_id"]) - } - if result[0]["tool_calls"] == nil { - t.Error("expected tool_calls to be present") - } - if result[0]["reasoning_content"] != "thinking..." { - t.Errorf("expected reasoning_content, got %v", result[0]["reasoning_content"]) - } -} - -func TestSerializeMessages_EmptyMediaUsesPlainFormat(t *testing.T) { - msgs := []Message{ - {Role: "user", Content: "hello", Media: []string{}}, - } - result := serializeMessages(msgs) - if _, ok := result[0]["content"].(string); !ok { - t.Errorf("empty Media should use plain string format, got %T", result[0]["content"]) - } -} diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index efac1e10b..99f13334e 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -65,7 +65,6 @@ type ContentBlock struct { type Message struct { Role string `json:"role"` Content string `json:"content"` - Media []string `json:"media,omitempty"` // URLs of images or other media attachments ReasoningContent string `json:"reasoning_content,omitempty"` SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` From 435223f500b56b81c2c238c36109401ef5f1ba99 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:04:28 +0800 Subject: [PATCH 71/82] * Add new style banner for picoclaw and picoclaw-launcher-tui (#1008) * Add new style banner for picoclaw and picoclaw-launcher-tui --- .../internal/ui/style.go | 22 ++++++++++++------- cmd/picoclaw/main.go | 14 ++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go index ff4f8b1a8..68cdd60b9 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/style.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/style.go @@ -5,6 +5,19 @@ import ( "github.com/rivo/tview" ) +const ( + colorBlue = "[#3e5db9]" + colorRed = "[#d54646]" + banner = "\r\n[::b]" + + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + + colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + + colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + + "[:]" +) + func applyStyles() { tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) @@ -24,14 +37,7 @@ func bannerView() *tview.TextView { text.SetDynamicColors(true) text.SetTextAlign(tview.AlignCenter) text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) - text.SetText( - "[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" + - "[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" + - "[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" + - "[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" + - "[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + - "[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝", - ) + text.SetText(banner) text.SetBorder(false) return text } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 6db69c990..d9263462e 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -48,7 +48,21 @@ func NewPicoclawCommand() *cobra.Command { return cmd } +const ( + colorBlue = "\033[1;38;2;62;93;185m" + colorRed = "\033[1;38;2;213;70;70m" + banner = "\r\n" + + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + + colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + + colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + + "\033[0m\r\n" +) + func main() { + fmt.Printf("%s", banner) cmd := NewPicoclawCommand() if err := cmd.Execute(); err != nil { os.Exit(1) From 6689c0b1c068a8149cb801b39cba959948a88272 Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 13:18:21 +0800 Subject: [PATCH 72/82] feat(providers): add Media field to Message struct for vision support Co-Authored-By: Claude Opus 4.6 --- pkg/providers/protocoltypes/types.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 99f13334e..194c1aa6f 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -65,6 +65,7 @@ type ContentBlock struct { type Message struct { Role string `json:"role"` Content string `json:"content"` + Media []string `json:"media,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` From 4c6c05a251895ad28cb7329664a3d028aa3d5948 Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 13:20:44 +0800 Subject: [PATCH 73/82] feat(config): add configurable max_media_size with 20MB default Co-Authored-By: Claude Opus 4.6 --- pkg/config/config.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/config/config.go b/pkg/config/config.go index 305ae67e3..eeca9638e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -180,6 +180,16 @@ type AgentDefaults struct { MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` +} + +const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB + +func (d *AgentDefaults) GetMaxMediaSize() int { + if d.MaxMediaSize > 0 { + return d.MaxMediaSize + } + return DefaultMaxMediaSize } // GetModelName returns the effective model name for the agent defaults. From 559cef3d5b27d8f5f13cc839c43dac91f853041c Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 13:23:18 +0800 Subject: [PATCH 74/82] chore: add h2non/filetype dependency for magic-bytes MIME detection Co-Authored-By: Claude Opus 4.6 --- go.mod | 2 ++ go.sum | 2 ++ 2 files changed, 4 insertions(+) diff --git a/go.mod b/go.mod index 1c699a724..c1172937c 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,8 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.13.8 // indirect + github.com/h2non/filetype v1.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/go.sum b/go.sum index 9041826a5..060594d06 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= From 6fd65825e731cf27a24b477bb5516dd2f2ea52b2 Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 14:01:52 +0800 Subject: [PATCH 75/82] feat(agent): implement resolveMediaRefs with streaming base64 and filetype detection Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop_media.go | 121 ++++++++++++++++++++++++++++++++++ pkg/agent/loop_test.go | 140 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 pkg/agent/loop_media.go diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go new file mode 100644 index 000000000..813feef69 --- /dev/null +++ b/pkg/agent/loop_media.go @@ -0,0 +1,121 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "bytes" + "encoding/base64" + "io" + "os" + "strings" + + "github.com/h2non/filetype" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs. +// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding +// both raw bytes and encoded string in memory simultaneously. +// Returns a new slice; original messages are not mutated. +func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { + if store == nil { + return messages + } + + result := make([]providers.Message, len(messages)) + copy(result, messages) + + for i, m := range result { + if len(m.Media) == 0 { + continue + } + + resolved := make([]string, 0, len(m.Media)) + for _, ref := range m.Media { + if !strings.HasPrefix(ref, "media://") { + resolved = append(resolved, ref) + continue + } + + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ + "ref": ref, + "error": err.Error(), + }) + continue + } + + info, err := os.Stat(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to stat media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + if info.Size() > int64(maxSize) { + logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ + "path": localPath, + "size": info.Size(), + "max_size": maxSize, + }) + continue + } + + // Determine MIME type: prefer metadata, fallback to magic-bytes detection + mime := meta.ContentType + if mime == "" { + kind, err := filetype.MatchFile(localPath) + if err != nil || kind == filetype.Unknown { + logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{ + "path": localPath, + }) + continue + } + mime = kind.MIME.Value + } + + // Streaming base64: open file → base64 encoder → buffer + // Peak memory: ~1.33x file size (buffer only, no raw bytes copy) + f, err := os.Open(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + + prefix := "data:" + mime + ";base64," + encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) + var buf bytes.Buffer + buf.Grow(len(prefix) + encodedLen) + buf.WriteString(prefix) + + encoder := base64.NewEncoder(base64.StdEncoding, &buf) + if _, err := io.Copy(encoder, f); err != nil { + f.Close() + logger.WarnCF("agent", "Failed to encode media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + encoder.Close() + f.Close() + + resolved = append(resolved, buf.String()) + } + + result[i].Media = resolved + } + + return result +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 3565314fe..4076c6e7c 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -6,12 +6,14 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -808,3 +810,141 @@ func TestHandleReasoning(t *testing.T) { } }) } + +func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // Create a minimal valid PNG (8-byte header is enough for filetype detection) + pngPath := filepath.Join(dir, "test.png") + // PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB + 0x00, 0x00, 0x00, // no interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + } + if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { + t.Fatal(err) + } + ref, err := store.Store(pngPath, media.MediaMeta{}, "test") + if err != nil { + t.Fatal(err) + } + + messages := []providers.Message{ + {Role: "user", Content: "describe this", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 1 { + t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) + } + if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { + t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40]) + } +} + +func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + bigPath := filepath.Join(dir, "big.png") + // Write PNG header + padding to exceed limit + data := make([]byte, 1024+1) // 1KB + 1 byte + copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) + if err := os.WriteFile(bigPath, data, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(bigPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + // Use a tiny limit (1KB) so the file is oversized + result := resolveMediaRefs(messages, store, 1024) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) + } +} + +func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + txtPath := filepath.Join(dir, "readme.txt") + if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(txtPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media)) + } +} + +func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) { + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}}, + } + result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" { + t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media) + } +} + +func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + pngPath := filepath.Join(dir, "test.png") + pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE} + os.WriteFile(pngPath, pngHeader, 0o644) + ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + original := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + originalRef := original[0].Media[0] + + resolveMediaRefs(original, store, config.DefaultMaxMediaSize) + + if original[0].Media[0] != originalRef { + t.Fatal("resolveMediaRefs mutated original message slice") + } +} + +func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // File with JPEG content but stored with explicit content type + jpegPath := filepath.Join(dir, "photo") + jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes + os.WriteFile(jpegPath, jpegHeader, 0o644) + ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 1 { + t.Fatalf("expected 1 media, got %d", len(result[0].Media)) + } + if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") { + t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) + } +} + From 03f7ae494f348ada4a7327486010a4ea88d05d9d Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 14:38:39 +0800 Subject: [PATCH 76/82] feat(openai_compat): implement serializeMessages with multipart media support Co-Authored-By: Claude Opus 4.6 --- pkg/providers/openai_compat/provider.go | 62 ++++++++++--- pkg/providers/openai_compat/provider_test.go | 98 ++++++++++++++++++++ 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 3a18b8b16..ff9109e96 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -116,7 +116,7 @@ func (p *Provider) Chat( requestBody := map[string]any{ "model": model, - "messages": stripSystemParts(messages), + "messages": serializeMessages(messages), } if len(tools) > 0 { @@ -296,19 +296,55 @@ type openaiMessage struct { ToolCallID string `json:"tool_call_id,omitempty"` } -// stripSystemParts converts []Message to []openaiMessage, dropping the -// SystemParts field so it doesn't leak into the JSON payload sent to -// OpenAI-compatible APIs (some strict endpoints reject unknown fields). -func stripSystemParts(messages []Message) []openaiMessage { - out := make([]openaiMessage, len(messages)) - for i, m := range messages { - out[i] = openaiMessage{ - Role: m.Role, - Content: m.Content, - ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, - ToolCallID: m.ToolCallID, +// serializeMessages converts internal Message structs to the OpenAI wire format. +// - Strips SystemParts (unknown to third-party endpoints) +// - Converts messages with Media to multipart content format (text + image_url parts) +// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages +func serializeMessages(messages []Message) []any { + out := make([]any, 0, len(messages)) + for _, m := range messages { + if len(m.Media) == 0 { + out = append(out, openaiMessage{ + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, + }) + continue } + + // Multipart content format for messages with media + parts := make([]map[string]any, 0, 1+len(m.Media)) + if m.Content != "" { + parts = append(parts, map[string]any{ + "type": "text", + "text": m.Content, + }) + } + for _, mediaURL := range m.Media { + parts = append(parts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": mediaURL, + }, + }) + } + + msg := map[string]any{ + "role": m.Role, + "content": parts, + } + if m.ToolCallID != "" { + msg["tool_call_id"] = m.ToolCallID + } + if len(m.ToolCalls) > 0 { + msg["tool_calls"] = m.ToolCalls + } + if m.ReasoningContent != "" { + msg["reasoning_content"] = m.ReasoningContent + } + out = append(out, msg) } return out } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 53b9e75ee..9d3b91a1a 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -5,8 +5,11 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { @@ -416,3 +419,98 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } + +func TestSerializeMessages_PlainText(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi", ReasoningContent: "thinking..."}, + } + result := serializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["content"] != "hello" { + t.Fatalf("expected plain string content, got %v", msgs[0]["content"]) + } + if msgs[1]["reasoning_content"] != "thinking..." { + t.Fatalf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"]) + } +} + +func TestSerializeMessages_WithMedia(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + content, ok := msgs[0]["content"].([]any) + if !ok { + t.Fatalf("expected array content for media message, got %T", msgs[0]["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + + textPart := content[0].(map[string]any) + if textPart["type"] != "text" || textPart["text"] != "describe this" { + t.Fatalf("text part mismatch: %v", textPart) + } + + imgPart := content[1].(map[string]any) + if imgPart["type"] != "image_url" { + t.Fatalf("expected image_url type, got %v", imgPart["type"]) + } + imgURL := imgPart["image_url"].(map[string]any) + if imgURL["url"] != "data:image/png;base64,abc123" { + t.Fatalf("image url mismatch: %v", imgURL["url"]) + } +} + +func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"}, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["tool_call_id"] != "call_1" { + t.Fatalf("tool_call_id not preserved with media, got %v", msgs[0]["tool_call_id"]) + } + // Content should be multipart array + if _, ok := msgs[0]["content"].([]any); !ok { + t.Fatalf("expected array content, got %T", msgs[0]["content"]) + } +} + +func TestSerializeMessages_StripsSystemParts(t *testing.T) { + messages := []protocoltypes.Message{ + { + Role: "system", + Content: "you are helpful", + SystemParts: []protocoltypes.ContentBlock{ + {Type: "text", Text: "you are helpful"}, + }, + }, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + raw := string(data) + if strings.Contains(raw, "system_parts") { + t.Fatal("system_parts should not appear in serialized output") + } +} + From 43227411eedde9c35aa5de37d3f12b0745bf2cf9 Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 14:52:57 +0800 Subject: [PATCH 77/82] feat(agent): wire media refs through agent pipeline to LLM provider Co-Authored-By: Claude Opus 4.6 --- pkg/agent/context.go | 8 ++++++-- pkg/agent/loop.go | 24 +++++++++++++++--------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 6fccbaf53..8a35d4457 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -466,10 +466,14 @@ func (cb *ContextBuilder) BuildMessages( // Add current user message if strings.TrimSpace(currentMessage) != "" { - messages = append(messages, providers.Message{ + msg := providers.Message{ Role: "user", Content: currentMessage, - }) + } + if len(media) > 0 { + msg.Media = media + } + messages = append(messages, msg) } return messages diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ac9b449a2..b803187b1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -47,14 +47,15 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - UserMessage string // User message content (may include prefix) - DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - NoHistory bool // If true, don't load session history (for heartbeat) + SessionKey string // Session identifier for history/context + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + UserMessage string // User message content (may include prefix) + Media []string // media:// refs from inbound message + DefaultResponse string // Response when LLM returns empty + EnableSummary bool // Whether to trigger summarization + SendResponse bool // Whether to send response via bus + NoHistory bool // If true, don't load session history (for heartbeat) } const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." @@ -497,6 +498,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, + Media: msg.Media, DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, @@ -603,11 +605,15 @@ func (al *AgentLoop) runAgentLoop( history, summary, opts.UserMessage, - nil, + opts.Media, opts.Channel, opts.ChatID, ) + // Resolve media:// refs to base64 data URLs (streaming) + maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize() + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + // 3. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) From fa1cb9cc74230182aac0ebfef9cb745cbb7079f7 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Tue, 3 Mar 2026 16:43:04 +0800 Subject: [PATCH 78/82] fix(feishu): address PR #1000 review comments from @xiaket - Consolidate extractImageKey/extractFileKey/extractFileName into shared extractJSONStringField helper to reduce code duplication - Move mentionPlaceholderRegex to package-level position after imports - Rename feishuCfg field to config for clarity within FeishuChannel - Replace @_user_1 heuristic with GET /open-apis/bot/v3/info API call at startup for reliable bot @mention detection - Fix double close on file handle in downloadResource by removing defer and using explicit close in both success and error paths - Add unit tests for common.go and feishu_64.go helpers (53 test cases) --- pkg/channels/feishu/common.go | 52 ++--- pkg/channels/feishu/common_test.go | 292 ++++++++++++++++++++++++++ pkg/channels/feishu/feishu_64.go | 82 +++++--- pkg/channels/feishu/feishu_64_test.go | 256 ++++++++++++++++++++++ 4 files changed, 629 insertions(+), 53 deletions(-) create mode 100644 pkg/channels/feishu/common_test.go create mode 100644 pkg/channels/feishu/feishu_64_test.go diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index cbae837a8..fbe085b73 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -8,6 +8,9 @@ import ( larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) +// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. +var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`) + // stringValue safely dereferences a *string pointer. func stringValue(v *string) string { if v == nil { @@ -37,43 +40,34 @@ func buildMarkdownCard(content string) (string, error) { return string(data), nil } -// extractImageKey extracts the image_key from a Feishu image message content JSON. -// Format: {"image_key": "img_xxx"} -func extractImageKey(content string) string { - var payload struct { - ImageKey string `json:"image_key"` - } - if err := json.Unmarshal([]byte(content), &payload); err != nil { +// extractJSONStringField unmarshals content as JSON and returns the value of the given string field. +// Returns "" if the content is invalid JSON or the field is missing/empty. +func extractJSONStringField(content, field string) string { + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(content), &m); err != nil { return "" } - return payload.ImageKey + raw, ok := m[field] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + return s } +// extractImageKey extracts the image_key from a Feishu image message content JSON. +// Format: {"image_key": "img_xxx"} +func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") } + // extractFileKey extracts the file_key from a Feishu file/audio message content JSON. // Format: {"file_key": "file_xxx", "file_name": "...", ...} -func extractFileKey(content string) string { - var payload struct { - FileKey string `json:"file_key"` - } - if err := json.Unmarshal([]byte(content), &payload); err != nil { - return "" - } - return payload.FileKey -} +func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } // extractFileName extracts the file_name from a Feishu file message content JSON. -func extractFileName(content string) string { - var payload struct { - FileName string `json:"file_name"` - } - if err := json.Unmarshal([]byte(content), &payload); err != nil { - return "" - } - return payload.FileName -} - -// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. -var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`) +func extractFileName(content string) string { return extractJSONStringField(content, "file_name") } // stripMentionPlaceholders removes @_user_N placeholders from the text content. // These are inserted by Feishu when users @mention someone in a message. diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go new file mode 100644 index 000000000..fefc9f7c1 --- /dev/null +++ b/pkg/channels/feishu/common_test.go @@ -0,0 +1,292 @@ +package feishu + +import ( + "encoding/json" + "testing" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +func TestExtractJSONStringField(t *testing.T) { + tests := []struct { + name string + content string + field string + want string + }{ + { + name: "valid field", + content: `{"image_key": "img_v2_xxx"}`, + field: "image_key", + want: "img_v2_xxx", + }, + { + name: "missing field", + content: `{"image_key": "img_v2_xxx"}`, + field: "file_key", + want: "", + }, + { + name: "invalid JSON", + content: `not json at all`, + field: "image_key", + want: "", + }, + { + name: "empty content", + content: "", + field: "image_key", + want: "", + }, + { + name: "non-string field value", + content: `{"count": 42}`, + field: "count", + want: "", + }, + { + name: "empty string value", + content: `{"image_key": ""}`, + field: "image_key", + want: "", + }, + { + name: "multiple fields", + content: `{"file_key": "file_xxx", "file_name": "test.pdf"}`, + field: "file_name", + want: "test.pdf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractJSONStringField(tt.content, tt.field) + if got != tt.want { + t.Errorf("extractJSONStringField(%q, %q) = %q, want %q", tt.content, tt.field, got, tt.want) + } + }) + } +} + +func TestExtractImageKey(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "normal", + content: `{"image_key": "img_v2_abc123"}`, + want: "img_v2_abc123", + }, + { + name: "missing key", + content: `{"file_key": "file_xxx"}`, + want: "", + }, + { + name: "malformed JSON", + content: `{broken`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractImageKey(tt.content) + if got != tt.want { + t.Errorf("extractImageKey(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestExtractFileKey(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "normal", + content: `{"file_key": "file_v2_abc123", "file_name": "test.doc"}`, + want: "file_v2_abc123", + }, + { + name: "missing key", + content: `{"image_key": "img_xxx"}`, + want: "", + }, + { + name: "malformed JSON", + content: `not json`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractFileKey(tt.content) + if got != tt.want { + t.Errorf("extractFileKey(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestExtractFileName(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "normal", + content: `{"file_key": "file_xxx", "file_name": "report.pdf"}`, + want: "report.pdf", + }, + { + name: "missing name", + content: `{"file_key": "file_xxx"}`, + want: "", + }, + { + name: "malformed JSON", + content: `{bad`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractFileName(tt.content) + if got != tt.want { + t.Errorf("extractFileName(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestBuildMarkdownCard(t *testing.T) { + tests := []struct { + name string + content string + }{ + { + name: "normal content", + content: "Hello **world**", + }, + { + name: "empty content", + content: "", + }, + { + name: "special characters", + content: `Code: "foo" & 'baz'`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := buildMarkdownCard(tt.content) + if err != nil { + t.Fatalf("buildMarkdownCard(%q) unexpected error: %v", tt.content, err) + } + + // Verify valid JSON + var parsed map[string]any + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("buildMarkdownCard(%q) produced invalid JSON: %v", tt.content, err) + } + + // Verify schema + if parsed["schema"] != "2.0" { + t.Errorf("schema = %v, want %q", parsed["schema"], "2.0") + } + + // Verify body.elements[0].content == input + body, ok := parsed["body"].(map[string]any) + if !ok { + t.Fatal("missing body in card JSON") + } + elements, ok := body["elements"].([]any) + if !ok || len(elements) == 0 { + t.Fatal("missing or empty elements in card JSON") + } + elem, ok := elements[0].(map[string]any) + if !ok { + t.Fatal("first element is not an object") + } + if elem["tag"] != "markdown" { + t.Errorf("tag = %v, want %q", elem["tag"], "markdown") + } + if elem["content"] != tt.content { + t.Errorf("content = %v, want %q", elem["content"], tt.content) + } + }) + } +} + +func TestStripMentionPlaceholders(t *testing.T) { + strPtr := func(s string) *string { return &s } + + tests := []struct { + name string + content string + mentions []*larkim.MentionEvent + want string + }{ + { + name: "no mentions", + content: "Hello world", + mentions: nil, + want: "Hello world", + }, + { + name: "single mention", + content: "@_user_1 hello", + mentions: []*larkim.MentionEvent{ + {Key: strPtr("@_user_1")}, + }, + want: "hello", + }, + { + name: "multiple mentions", + content: "@_user_1 @_user_2 hey", + mentions: []*larkim.MentionEvent{ + {Key: strPtr("@_user_1")}, + {Key: strPtr("@_user_2")}, + }, + want: "hey", + }, + { + name: "empty content", + content: "", + mentions: []*larkim.MentionEvent{{Key: strPtr("@_user_1")}}, + want: "", + }, + { + name: "empty mentions slice", + content: "@_user_1 test", + mentions: []*larkim.MentionEvent{}, + want: "@_user_1 test", + }, + { + name: "mention with nil key", + content: "@_user_1 test", + mentions: []*larkim.MentionEvent{ + {Key: nil}, + }, + want: "test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripMentionPlaceholders(tt.content, tt.mentions) + if got != tt.want { + t.Errorf("stripMentionPlaceholders(%q, ...) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index f8b779e71..00f73064d 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -7,12 +7,14 @@ import ( "encoding/json" "fmt" "io" + "net/http" "os" "path/filepath" "sync" "sync/atomic" lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkws "github.com/larksuite/oapi-sdk-go/v3/ws" @@ -28,9 +30,9 @@ import ( type FeishuChannel struct { *channels.BaseChannel - feishuCfg config.FeishuConfig - client *lark.Client - wsClient *larkws.Client + config config.FeishuConfig + client *lark.Client + wsClient *larkws.Client botOpenID atomic.Value // stores string; populated lazily for @mention detection @@ -46,7 +48,7 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan ch := &FeishuChannel{ BaseChannel: base, - feishuCfg: cfg, + config: cfg, client: lark.NewClient(cfg.AppID, cfg.AppSecret), } ch.SetOwner(ch) @@ -54,13 +56,18 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan } func (c *FeishuChannel) Start(ctx context.Context) error { - if c.feishuCfg.AppID == "" || c.feishuCfg.AppSecret == "" { + if c.config.AppID == "" || c.config.AppSecret == "" { return fmt.Errorf("feishu app_id or app_secret is empty") } - // Bot open_id for @mention detection is populated lazily from the first mention event. + // Fetch bot open_id via API for reliable @mention detection. + if err := c.fetchBotOpenID(ctx); err != nil { + logger.ErrorCF("feishu", "Failed to fetch bot open_id, @mention detection may not work", map[string]any{ + "error": err.Error(), + }) + } - dispatcher := larkdispatcher.NewEventDispatcher(c.feishuCfg.VerificationToken, c.feishuCfg.EncryptKey). + dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey). OnP2MessageReceiveV1(c.handleMessageReceive) runCtx, cancel := context.WithCancel(ctx) @@ -68,8 +75,8 @@ func (c *FeishuChannel) Start(ctx context.Context) error { c.mu.Lock() c.cancel = cancel c.wsClient = larkws.NewClient( - c.feishuCfg.AppID, - c.feishuCfg.AppSecret, + c.config.AppID, + c.config.AppSecret, larkws.WithEventHandler(dispatcher), ) wsClient := c.wsClient @@ -147,14 +154,14 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.feishuCfg.Placeholder.Enabled { + if !c.config.Placeholder.Enabled { logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{ "chat_id": chatID, }) return "", nil } - text := c.feishuCfg.Placeholder.Text + text := c.config.Placeholder.Text if text == "" { text = "Thinking..." } @@ -409,6 +416,40 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. // --- Internal helpers --- +// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id. +func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error { + resp, err := c.client.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/bot/v3/info", + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, + }) + if err != nil { + return fmt.Errorf("bot info request: %w", err) + } + + var result struct { + Code int `json:"code"` + Bot struct { + OpenID string `json:"open_id"` + } `json:"bot"` + } + if err := json.Unmarshal(resp.RawBody, &result); err != nil { + return fmt.Errorf("bot info parse: %w", err) + } + if result.Code != 0 { + return fmt.Errorf("bot info api error (code=%d)", result.Code) + } + if result.Bot.OpenID == "" { + return fmt.Errorf("bot info: empty open_id") + } + + c.botOpenID.Store(result.Bot.OpenID) + logger.InfoCF("feishu", "Fetched bot open_id from API", map[string]any{ + "open_id": result.Bot.OpenID, + }) + return nil +} + // isBotMentioned checks if the bot was @mentioned in the message. func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool { if message.Mentions == nil { @@ -416,23 +457,16 @@ func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool { } knownID, _ := c.botOpenID.Load().(string) + if knownID == "" { + logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil) + return false + } for _, m := range message.Mentions { if m.Id == nil { continue } - // If we already know the bot's open_id, match against it. - if m.Id.OpenId != nil && knownID != "" && *m.Id.OpenId == knownID { - return true - } - // If we don't know our bot open_id yet, use a reliable heuristic: - // Feishu assigns @_user_1 as the key for the first mention (the bot itself) - // when a user @mentions the bot. Only trust this specific key. - if knownID == "" && m.Key != nil && *m.Key == "@_user_1" && m.Id.OpenId != nil { - c.botOpenID.Store(*m.Id.OpenId) - logger.DebugCF("feishu", "Detected bot open_id from @_user_1 mention", map[string]any{ - "open_id": *m.Id.OpenId, - }) + if m.Id.OpenId != nil && *m.Id.OpenId == knownID { return true } } @@ -587,7 +621,6 @@ func (c *FeishuChannel) downloadResource( }) return "" } - defer out.Close() if _, copyErr := io.Copy(out, resp.File); copyErr != nil { out.Close() @@ -597,6 +630,7 @@ func (c *FeishuChannel) downloadResource( }) return "" } + out.Close() ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go new file mode 100644 index 000000000..dc3eab2e7 --- /dev/null +++ b/pkg/channels/feishu/feishu_64_test.go @@ -0,0 +1,256 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu + +import ( + "testing" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +func TestExtractContent(t *testing.T) { + tests := []struct { + name string + messageType string + rawContent string + want string + }{ + { + name: "text message", + messageType: "text", + rawContent: `{"text": "hello world"}`, + want: "hello world", + }, + { + name: "text message invalid JSON", + messageType: "text", + rawContent: `not json`, + want: "not json", + }, + { + name: "post message returns raw JSON", + messageType: "post", + rawContent: `{"title": "test post"}`, + want: `{"title": "test post"}`, + }, + { + name: "image message returns empty", + messageType: "image", + rawContent: `{"image_key": "img_xxx"}`, + want: "", + }, + { + name: "file message with filename", + messageType: "file", + rawContent: `{"file_key": "file_xxx", "file_name": "report.pdf"}`, + want: "report.pdf", + }, + { + name: "file message without filename", + messageType: "file", + rawContent: `{"file_key": "file_xxx"}`, + want: "", + }, + { + name: "audio message with filename", + messageType: "audio", + rawContent: `{"file_key": "file_xxx", "file_name": "recording.ogg"}`, + want: "recording.ogg", + }, + { + name: "media message with filename", + messageType: "media", + rawContent: `{"file_key": "file_xxx", "file_name": "video.mp4"}`, + want: "video.mp4", + }, + { + name: "unknown message type returns raw", + messageType: "sticker", + rawContent: `{"sticker_id": "sticker_xxx"}`, + want: `{"sticker_id": "sticker_xxx"}`, + }, + { + name: "empty raw content", + messageType: "text", + rawContent: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractContent(tt.messageType, tt.rawContent) + if got != tt.want { + t.Errorf("extractContent(%q, %q) = %q, want %q", tt.messageType, tt.rawContent, got, tt.want) + } + }) + } +} + +func TestAppendMediaTags(t *testing.T) { + tests := []struct { + name string + content string + messageType string + mediaRefs []string + want string + }{ + { + name: "no refs returns content unchanged", + content: "hello", + messageType: "image", + mediaRefs: nil, + want: "hello", + }, + { + name: "empty refs returns content unchanged", + content: "hello", + messageType: "image", + mediaRefs: []string{}, + want: "hello", + }, + { + name: "image with content", + content: "check this", + messageType: "image", + mediaRefs: []string{"ref1"}, + want: "check this [image: photo]", + }, + { + name: "image empty content", + content: "", + messageType: "image", + mediaRefs: []string{"ref1"}, + want: "[image: photo]", + }, + { + name: "audio", + content: "listen", + messageType: "audio", + mediaRefs: []string{"ref1"}, + want: "listen [audio]", + }, + { + name: "media/video", + content: "watch", + messageType: "media", + mediaRefs: []string{"ref1"}, + want: "watch [video]", + }, + { + name: "file", + content: "report.pdf", + messageType: "file", + mediaRefs: []string{"ref1"}, + want: "report.pdf [file]", + }, + { + name: "unknown type", + content: "something", + messageType: "sticker", + mediaRefs: []string{"ref1"}, + want: "something [attachment]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendMediaTags(tt.content, tt.messageType, tt.mediaRefs) + if got != tt.want { + t.Errorf( + "appendMediaTags(%q, %q, %v) = %q, want %q", + tt.content, + tt.messageType, + tt.mediaRefs, + got, + tt.want, + ) + } + }) + } +} + +func TestExtractFeishuSenderID(t *testing.T) { + strPtr := func(s string) *string { return &s } + + tests := []struct { + name string + sender *larkim.EventSender + want string + }{ + { + name: "nil sender", + sender: nil, + want: "", + }, + { + name: "nil sender ID", + sender: &larkim.EventSender{SenderId: nil}, + want: "", + }, + { + name: "userId preferred", + sender: &larkim.EventSender{ + SenderId: &larkim.UserId{ + UserId: strPtr("u_abc123"), + OpenId: strPtr("ou_def456"), + UnionId: strPtr("on_ghi789"), + }, + }, + want: "u_abc123", + }, + { + name: "openId fallback", + sender: &larkim.EventSender{ + SenderId: &larkim.UserId{ + UserId: strPtr(""), + OpenId: strPtr("ou_def456"), + UnionId: strPtr("on_ghi789"), + }, + }, + want: "ou_def456", + }, + { + name: "unionId fallback", + sender: &larkim.EventSender{ + SenderId: &larkim.UserId{ + UserId: strPtr(""), + OpenId: strPtr(""), + UnionId: strPtr("on_ghi789"), + }, + }, + want: "on_ghi789", + }, + { + name: "all empty strings", + sender: &larkim.EventSender{ + SenderId: &larkim.UserId{ + UserId: strPtr(""), + OpenId: strPtr(""), + UnionId: strPtr(""), + }, + }, + want: "", + }, + { + name: "nil userId pointer falls through", + sender: &larkim.EventSender{ + SenderId: &larkim.UserId{ + UserId: nil, + OpenId: strPtr("ou_def456"), + UnionId: nil, + }, + }, + want: "ou_def456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractFeishuSenderID(tt.sender) + if got != tt.want { + t.Errorf("extractFeishuSenderID() = %q, want %q", got, tt.want) + } + }) + } +} From 6ccb68c63e8daccec339f6ec02b7b87ecc8334b1 Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 17:04:13 +0800 Subject: [PATCH 79/82] fix: resolve linter issues (gci import grouping, gofumpt, govet shadow) - Separate third-party imports from local module imports (gci) - Fix byte slice literal formatting (gofumpt) - Rename shadowed err variable to ftErr (govet) - Remove trailing blank lines in test files Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop_media.go | 5 +++-- pkg/agent/loop_test.go | 7 ++++--- pkg/providers/openai_compat/provider_test.go | 1 - 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go index 813feef69..82547a008 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/loop_media.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/h2non/filetype" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" @@ -72,8 +73,8 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS // Determine MIME type: prefer metadata, fallback to magic-bytes detection mime := meta.ContentType if mime == "" { - kind, err := filetype.MatchFile(localPath) - if err != nil || kind == filetype.Unknown { + kind, ftErr := filetype.MatchFile(localPath) + if ftErr != nil || kind == filetype.Unknown { logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{ "path": localPath, }) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4076c6e7c..023286f02 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -906,10 +906,12 @@ func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() pngPath := filepath.Join(dir, "test.png") - pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, - 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE} + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } os.WriteFile(pngPath, pngHeader, 0o644) ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") @@ -947,4 +949,3 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) } } - diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 9d3b91a1a..174bcf00d 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -513,4 +513,3 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { t.Fatal("system_parts should not appear in serialized output") } } - From 1265655ef09427b26028b560d47643a9e83cb211 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:27:57 +0800 Subject: [PATCH 80/82] feat(telegram): add base_url support for custom Telegram Bot API server (#1021) * feat(telegram): add base_url support for custom Telegram Bot API server Allow users to specify a custom Telegram Bot API server URL via config field `base_url` or env var `PICOCLAW_CHANNELS_TELEGRAM_BASE_URL`. Defaults to the official https://api.telegram.org when left empty. Co-Authored-By: Claude Opus 4.6 * fix(telegram): trim whitespace and trailing slash from base_url Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- config/config.example.json | 1 + pkg/channels/telegram/telegram.go | 4 ++++ pkg/config/config.go | 1 + 3 files changed, 6 insertions(+) diff --git a/config/config.example.json b/config/config.example.json index fe3740289..3c84cfa9f 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -49,6 +49,7 @@ "telegram": { "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", + "base_url": "", "proxy": "", "allow_from": [ "YOUR_USER_ID" diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 7feb706aa..f328f32b8 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -72,6 +72,10 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann })) } + if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { + opts = append(opts, telego.WithAPIServer(baseURL)) + } + bot, err := telego.NewBot(telegramCfg.Token, opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) diff --git a/pkg/config/config.go b/pkg/config/config.go index 305ae67e3..bbd684bc0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -237,6 +237,7 @@ type WhatsAppConfig struct { type TelegramConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` From 7de4cc5ebde197bd89b3ba9e1ad772797ac8abfe Mon Sep 17 00:00:00 2001 From: wangyanfu2 Date: Tue, 3 Mar 2026 17:50:29 +0800 Subject: [PATCH 81/82] fix: add HTTP status code check in BraveSearchProvider - Add status code validation after reading response body, consistent with TavilySearchProvider and PerplexitySearchProvider --- pkg/tools/web.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 10498126b..15d2330ff 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -109,6 +109,10 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in return "", fmt.Errorf("failed to read response: %w", err) } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("brave api error (status %d): %s", resp.StatusCode, string(body)) + } + var searchResp struct { Web struct { Results []struct { From 3902061db141a7fe1cb7cb68d12eaf476cb49039 Mon Sep 17 00:00:00 2001 From: pikaxinge <68273313+pikaxinge@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:25:00 +0800 Subject: [PATCH 82/82] fix(agent): invalidate system prompt cache for global/builtin skills (#845) * fix(agent): invalidate system prompt cache for global/builtin skills * test(agent): avoid os.Chdir in builtin skill cache test * fix(agent): harden skill cache invalidation checks --- README.md | 14 +++ README.zh.md | 14 +++ pkg/agent/context.go | 173 +++++++++++++++++++++----------- pkg/agent/context_cache_test.go | 156 ++++++++++++++++++++++++++++ pkg/skills/loader.go | 23 +++++ pkg/skills/loader_test.go | 16 +++ 6 files changed, 340 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 6714ac6eb..c5b38e222 100644 --- a/README.md +++ b/README.md @@ -721,6 +721,20 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa └── USER.md # User preferences ``` +### Skill Sources + +By default, skills are loaded from: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin) + +For advanced/test setups, you can override the builtin skills root with: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. diff --git a/README.zh.md b/README.zh.md index d3a49ee8d..db96ba555 100644 --- a/README.zh.md +++ b/README.zh.md @@ -362,6 +362,20 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ``` +### 技能来源 (Skill Sources) + +默认情况下,技能会按以下顺序加载: + +1. `~/.picoclaw/workspace/skills`(工作区) +2. `~/.picoclaw/skills`(全局) +3. `/skills`(内置) + +在高级/测试场景下,可通过以下环境变量覆盖内置技能目录: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + ### 心跳 / 周期性任务 (Heartbeat) PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 6fccbaf53..8aac3bc62 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -34,6 +34,11 @@ type ContextBuilder struct { // created (didn't exist at cache time, now exist) or deleted (existed at // cache time, now gone) — both of which should trigger a cache rebuild. existedAtCache map[string]bool + + // skillFilesAtCache snapshots the skill tree file set and mtimes at cache + // build time. This catches nested file creations/deletions/mtime changes + // that may not update the top-level skill root directory mtime. + skillFilesAtCache map[string]time.Time } func getGlobalConfigDir() string { @@ -47,8 +52,11 @@ func getGlobalConfigDir() string { func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project // Use the skills/ directory under the current working directory - wd, _ := os.Getwd() - builtinSkillsDir := filepath.Join(wd, "skills") + builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS")) + if builtinSkillsDir == "" { + wd, _ := os.Getwd() + builtinSkillsDir = filepath.Join(wd, "skills") + } globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") return &ContextBuilder{ @@ -148,6 +156,7 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { cb.cachedSystemPrompt = prompt cb.cachedAt = baseline.maxMtime cb.existedAtCache = baseline.existed + cb.skillFilesAtCache = baseline.skillFiles logger.DebugCF("agent", "System prompt cached", map[string]any{ @@ -167,14 +176,14 @@ func (cb *ContextBuilder) InvalidateCache() { cb.cachedSystemPrompt = "" cb.cachedAt = time.Time{} cb.existedAtCache = nil + cb.skillFilesAtCache = nil logger.DebugCF("agent", "System prompt cache invalidated", nil) } -// sourcePaths returns the workspace source file paths tracked for cache -// invalidation (bootstrap files + memory). The skills directory is handled -// separately in sourceFilesChangedLocked because it requires both directory- -// level and recursive file-level mtime checks. +// sourcePaths returns non-skill workspace source files tracked for cache +// invalidation (bootstrap files + memory). Skill roots are handled separately +// because they require both directory-level and recursive file-level checks. func (cb *ContextBuilder) sourcePaths() []string { return []string{ filepath.Join(cb.workspace, "AGENTS.md"), @@ -185,23 +194,39 @@ func (cb *ContextBuilder) sourcePaths() []string { } } +// skillRoots returns all skill root directories that can affect +// BuildSkillsSummary output (workspace/global/builtin). +func (cb *ContextBuilder) skillRoots() []string { + if cb.skillsLoader == nil { + return []string{filepath.Join(cb.workspace, "skills")} + } + + roots := cb.skillsLoader.SkillRoots() + if len(roots) == 0 { + return []string{filepath.Join(cb.workspace, "skills")} + } + return roots +} + // cacheBaseline holds the file existence snapshot and the latest observed // mtime across all tracked paths. Used as the cache reference point. type cacheBaseline struct { - existed map[string]bool - maxMtime time.Time + existed map[string]bool + skillFiles map[string]time.Time + maxMtime time.Time } // buildCacheBaseline records which tracked paths currently exist and computes // the latest mtime across all tracked files + skills directory contents. // Called under write lock when the cache is built. func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { - skillsDir := filepath.Join(cb.workspace, "skills") + skillRoots := cb.skillRoots() - // All paths whose existence we track: source files + skills dir. - allPaths := append(cb.sourcePaths(), skillsDir) + // All paths whose existence we track: source files + all skill roots. + allPaths := append(cb.sourcePaths(), skillRoots...) existed := make(map[string]bool, len(allPaths)) + skillFiles := make(map[string]time.Time) var maxMtime time.Time for _, p := range allPaths { @@ -212,17 +237,21 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { } } - // Walk skills files to capture their mtimes too. - // Use os.Stat (not d.Info) to match the stat method used in - // fileChangedSince / skillFilesModifiedSince for consistency. - _ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr == nil && !d.IsDir() { - if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) { - maxMtime = info.ModTime() + // Walk all skill roots recursively to snapshot skill files and mtimes. + // Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks. + for _, root := range skillRoots { + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr == nil && !d.IsDir() { + if info, err := os.Stat(path); err == nil { + skillFiles[path] = info.ModTime() + if info.ModTime().After(maxMtime) { + maxMtime = info.ModTime() + } + } } - } - return nil - }) + return nil + }) + } // If no tracked files exist yet (empty workspace), maxMtime is zero. // Use a very old non-zero time so that: @@ -234,7 +263,7 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { maxMtime = time.Unix(1, 0) } - return cacheBaseline{existed: existed, maxMtime: maxMtime} + return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime} } // sourceFilesChangedLocked checks whether any workspace source file has been @@ -254,21 +283,17 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { return true } - // --- Skills directory (handled separately from sourcePaths) --- + // --- Skill roots (workspace/global/builtin) --- // - // 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files. - skillsDir := filepath.Join(cb.workspace, "skills") - if cb.fileChangedSince(skillsDir) { - return true + // For each root: + // 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince. + // 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot. + for _, root := range cb.skillRoots() { + if cb.fileChangedSince(root) { + return true + } } - - // 2. Structural changes (add/remove entries inside the dir) are reflected - // in the directory's own mtime, which fileChangedSince already checks. - // - // 3. Content-only edits to files inside skills/ do NOT update the parent - // directory mtime on most filesystems, so we recursively walk to check - // individual file mtimes at any nesting depth. - if skillFilesModifiedSince(skillsDir, cb.cachedAt) { + if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) { return true } @@ -309,28 +334,64 @@ func (cb *ContextBuilder) fileChangedSince(path string) bool { // if the callback returned nil when its err parameter is non-nil. var errWalkStop = errors.New("walk stop") -// skillFilesModifiedSince recursively walks the skills directory and checks -// whether any file was modified after t. This catches content-only edits at -// any nesting depth (e.g. skills/name/docs/extra.md) that don't update -// parent directory mtimes. -func skillFilesModifiedSince(skillsDir string, t time.Time) bool { - changed := false - err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr == nil && !d.IsDir() { - if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) { - changed = true - return errWalkStop // stop walking - } - } - return nil - }) - // errWalkStop is expected (early exit on first changed file). - // os.IsNotExist means the skills dir doesn't exist yet — not an error. - // Any other error is unexpected and worth logging. - if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) { - logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()}) +// skillFilesChangedSince compares the current recursive skill file tree +// against the cache-time snapshot. Any create/delete/mtime drift invalidates +// the cache. +func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool { + // Defensive: if the snapshot was never initialized, force rebuild. + if filesAtCache == nil { + return true } - return changed + + // Check cached files still exist and keep the same mtime. + for path, cachedMtime := range filesAtCache { + info, err := os.Stat(path) + if err != nil { + // A previously tracked file disappeared (or became inaccessible): + // either way, cached skill summary may now be stale. + return true + } + if !info.ModTime().Equal(cachedMtime) { + return true + } + } + + // Check no new files appeared under any skill root. + changed := false + for _, root := range skillRoots { + if strings.TrimSpace(root) == "" { + continue + } + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + // Treat unexpected walk errors as changed to avoid stale cache. + if !os.IsNotExist(walkErr) { + changed = true + return errWalkStop + } + return nil + } + if d.IsDir() { + return nil + } + if _, ok := filesAtCache[path]; !ok { + changed = true + return errWalkStop + } + return nil + }) + + if changed { + return true + } + if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) { + logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()}) + return true + } + } + + return false } func (cb *ContextBuilder) LoadBootstrapFiles() string { diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 0905e8a46..707510820 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -383,6 +383,162 @@ Updated content.` } } +// TestGlobalSkillFileContentChange verifies that modifying a global skill +// (~/.picoclaw/skills) invalidates the cached system prompt. +func TestGlobalSkillFileContentChange(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + tmpDir := setupWorkspace(t, nil) + defer os.RemoveAll(tmpDir) + + globalSkillPath := filepath.Join(tmpHome, ".picoclaw", "skills", "global-skill", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(globalSkillPath), 0o755); err != nil { + t.Fatal(err) + } + v1 := `--- +name: global-skill +description: global-v1 +--- +# Global Skill v1` + if err := os.WriteFile(globalSkillPath, []byte(v1), 0o644); err != nil { + t.Fatal(err) + } + + cb := NewContextBuilder(tmpDir) + sp1 := cb.BuildSystemPromptWithCache() + if !strings.Contains(sp1, "global-v1") { + t.Fatal("expected initial prompt to contain global skill description") + } + + v2 := `--- +name: global-skill +description: global-v2 +--- +# Global Skill v2` + if err := os.WriteFile(globalSkillPath, []byte(v2), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(globalSkillPath, future, future); err != nil { + t.Fatalf("failed to update mtime for %s: %v", globalSkillPath, err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if !changed { + t.Fatal("sourceFilesChangedLocked() should detect global skill file content change") + } + + sp2 := cb.BuildSystemPromptWithCache() + if !strings.Contains(sp2, "global-v2") { + t.Error("rebuilt prompt should contain updated global skill description") + } + if sp1 == sp2 { + t.Error("cache should be invalidated when global skill file content changes") + } +} + +// TestBuiltinSkillFileContentChange verifies that modifying a builtin skill +// invalidates the cached system prompt. +func TestBuiltinSkillFileContentChange(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + tmpDir := setupWorkspace(t, nil) + defer os.RemoveAll(tmpDir) + + builtinRoot := t.TempDir() + t.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot) + + builtinSkillPath := filepath.Join(builtinRoot, "builtin-skill", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(builtinSkillPath), 0o755); err != nil { + t.Fatal(err) + } + v1 := `--- +name: builtin-skill +description: builtin-v1 +--- +# Builtin Skill v1` + if err := os.WriteFile(builtinSkillPath, []byte(v1), 0o644); err != nil { + t.Fatal(err) + } + + cb := NewContextBuilder(tmpDir) + sp1 := cb.BuildSystemPromptWithCache() + if !strings.Contains(sp1, "builtin-v1") { + t.Fatal("expected initial prompt to contain builtin skill description") + } + + v2 := `--- +name: builtin-skill +description: builtin-v2 +--- +# Builtin Skill v2` + if err := os.WriteFile(builtinSkillPath, []byte(v2), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(builtinSkillPath, future, future); err != nil { + t.Fatalf("failed to update mtime for %s: %v", builtinSkillPath, err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if !changed { + t.Fatal("sourceFilesChangedLocked() should detect builtin skill file content change") + } + + sp2 := cb.BuildSystemPromptWithCache() + if !strings.Contains(sp2, "builtin-v2") { + t.Error("rebuilt prompt should contain updated builtin skill description") + } + if sp1 == sp2 { + t.Error("cache should be invalidated when builtin skill file content changes") + } +} + +// TestSkillFileDeletionInvalidatesCache verifies that deleting a nested skill +// file invalidates the cached system prompt. +func TestSkillFileDeletionInvalidatesCache(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "skills/delete-me/SKILL.md": `--- +name: delete-me +description: delete-me-v1 +--- +# Delete Me`, + }) + defer os.RemoveAll(tmpDir) + + cb := NewContextBuilder(tmpDir) + sp1 := cb.BuildSystemPromptWithCache() + if !strings.Contains(sp1, "delete-me-v1") { + t.Fatal("expected initial prompt to contain skill description") + } + + skillPath := filepath.Join(tmpDir, "skills", "delete-me", "SKILL.md") + if err := os.Remove(skillPath); err != nil { + t.Fatal(err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if !changed { + t.Fatal("sourceFilesChangedLocked() should detect deleted skill file") + } + + sp2 := cb.BuildSystemPromptWithCache() + if strings.Contains(sp2, "delete-me-v1") { + t.Error("rebuilt prompt should not contain deleted skill description") + } + if sp1 == sp2 { + t.Error("cache should be invalidated when skill file is deleted") + } +} + // TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines // can safely call BuildSystemPromptWithCache concurrently without producing // empty results, panics, or data races. diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index fcbcf934b..30d84635a 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -64,6 +64,29 @@ type SkillsLoader struct { builtinSkills string // builtin skills } +// SkillRoots returns all unique skill root directories used by this loader. +// The order follows resolution priority: workspace > global > builtin. +func (sl *SkillsLoader) SkillRoots() []string { + roots := []string{sl.workspaceSkills, sl.globalSkills, sl.builtinSkills} + seen := make(map[string]struct{}, len(roots)) + out := make([]string, 0, len(roots)) + + for _, root := range roots { + trimmed := strings.TrimSpace(root) + if trimmed == "" { + continue + } + clean := filepath.Clean(trimmed) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + out = append(out, clean) + } + + return out +} + func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader { return &SkillsLoader{ workspace: workspace, diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index 9428bea62..31619f9c2 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -326,3 +326,19 @@ func TestStripFrontmatter(t *testing.T) { }) } } + +func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + builtin := filepath.Join(tmp, "builtin") + + sl := NewSkillsLoader(workspace, " "+global+" ", "\t"+builtin+"\n") + roots := sl.SkillRoots() + + assert.Equal(t, []string{ + filepath.Join(workspace, "skills"), + global, + builtin, + }, roots) +}