From 91c168db2042998fa90ec993febf0cc2d0b3f75c Mon Sep 17 00:00:00 2001 From: yuchou87 Date: Sun, 15 Feb 2026 17:26:36 +0800 Subject: [PATCH 001/132] 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 002/132] 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 003/132] 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 004/132] 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 005/132] 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 006/132] 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 007/132] 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 008/132] 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 009/132] 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 010/132] 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 011/132] 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 012/132] 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 013/132] 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 014/132] 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 015/132] 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 016/132] 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 017/132] 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 018/132] 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 019/132] 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 020/132] 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 021/132] 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 022/132] 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 023/132] 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 024/132] 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 025/132] 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 026/132] 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 027/132] 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 028/132] 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 029/132] 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 030/132] 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 031/132] 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 032/132] 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 033/132] 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 034/132] 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 035/132] 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 036/132] 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 037/132] 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 038/132] 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 039/132] 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 040/132] 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 041/132] 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 042/132] 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 043/132] 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 044/132] 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 045/132] 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 046/132] 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 32ec8cadeb039fca8430520bed8f45b9f4f3f1b0 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Tue, 24 Feb 2026 22:21:29 +0800 Subject: [PATCH 047/132] feat(memory): define Store interface for session persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a backend-agnostic Store interface in pkg/memory/ that maps one-to-one with the current SessionManager API. Each method is atomic — no separate Save() call needed. Refs #711 --- pkg/memory/store.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 pkg/memory/store.go diff --git a/pkg/memory/store.go b/pkg/memory/store.go new file mode 100644 index 000000000..6887ec26e --- /dev/null +++ b/pkg/memory/store.go @@ -0,0 +1,38 @@ +package memory + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Store defines an interface for persistent session storage. +// Each method is an atomic operation — there is no separate Save() call. +type Store interface { + // AddMessage appends a simple text message to a session. + AddMessage(ctx context.Context, sessionKey, role, content string) error + + // AddFullMessage appends a complete message (with tool calls, etc.) to a session. + AddFullMessage(ctx context.Context, sessionKey string, msg providers.Message) error + + // GetHistory returns all messages for a session in insertion order. + // Returns an empty slice (not nil) if the session does not exist. + GetHistory(ctx context.Context, sessionKey string) ([]providers.Message, error) + + // GetSummary returns the conversation summary for a session. + // Returns an empty string if no summary exists. + GetSummary(ctx context.Context, sessionKey string) (string, error) + + // SetSummary updates the conversation summary for a session. + SetSummary(ctx context.Context, sessionKey, summary string) error + + // TruncateHistory removes all but the last keepLast messages from a session. + // If keepLast <= 0, all messages are removed. + TruncateHistory(ctx context.Context, sessionKey string, keepLast int) error + + // SetHistory replaces all messages in a session with the provided history. + SetHistory(ctx context.Context, sessionKey string, history []providers.Message) error + + // Close releases any resources held by the store. + Close() error +} From 9f36e50807093181d13619b2f21d988f98553276 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Tue, 24 Feb 2026 22:21:42 +0800 Subject: [PATCH 048/132] feat(memory): implement append-only JSONL session store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add JSONLStore that persists sessions as .jsonl files (one message per line) plus .meta.json for summary and truncation offset. Key design decisions: - Append-only writes — no full-file rewrites on AddMessage - Logical truncation via skip offset instead of physical deletion - Per-session mutex for safe concurrent access - Crash recovery: malformed trailing lines are silently skipped - Atomic metadata writes using temp+rename Zero new dependencies — pure stdlib. Refs #711 --- pkg/memory/jsonl.go | 386 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 pkg/memory/jsonl.go diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go new file mode 100644 index 000000000..266f453d9 --- /dev/null +++ b/pkg/memory/jsonl.go @@ -0,0 +1,386 @@ +package memory + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// sessionMeta holds per-session metadata stored in a .meta.json file. +type sessionMeta struct { + Key string `json:"key"` + Summary string `json:"summary"` + Skip int `json:"skip"` + Count int `json:"count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// JSONLStore implements Store using append-only JSONL files. +// +// Each session is stored as two files: +// +// {sanitized_key}.jsonl — one JSON-encoded message per line, append-only +// {sanitized_key}.meta.json — session metadata (summary, logical truncation offset) +// +// Messages are never physically deleted from the JSONL file. Instead, +// TruncateHistory records a "skip" offset in the metadata file and +// GetHistory ignores lines before that offset. This keeps all writes +// append-only, which is both fast and crash-safe. +type JSONLStore struct { + dir string + + mu sync.Mutex + locks map[string]*sync.Mutex +} + +// NewJSONLStore creates a new JSONL-backed store rooted at dir. +func NewJSONLStore(dir string) (*JSONLStore, error) { + err := os.MkdirAll(dir, 0o755) + if err != nil { + return nil, fmt.Errorf("memory: create directory: %w", err) + } + return &JSONLStore{ + dir: dir, + locks: make(map[string]*sync.Mutex), + }, nil +} + +// sessionLock returns (or creates) a per-session mutex. +func (s *JSONLStore) sessionLock(key string) *sync.Mutex { + s.mu.Lock() + defer s.mu.Unlock() + + l, ok := s.locks[key] + if !ok { + l = &sync.Mutex{} + s.locks[key] = l + } + return l +} + +func (s *JSONLStore) jsonlPath(key string) string { + return filepath.Join(s.dir, sanitizeKey(key)+".jsonl") +} + +func (s *JSONLStore) metaPath(key string) string { + return filepath.Join(s.dir, sanitizeKey(key)+".meta.json") +} + +// sanitizeKey converts a session key to a safe filename component. +// Mirrors pkg/session.sanitizeFilename so that migration paths match. +func sanitizeKey(key string) string { + return strings.ReplaceAll(key, ":", "_") +} + +// readMeta loads the metadata file for a session. +// Returns a zero-value sessionMeta if the file does not exist. +func (s *JSONLStore) readMeta(key string) (sessionMeta, error) { + data, err := os.ReadFile(s.metaPath(key)) + if os.IsNotExist(err) { + return sessionMeta{Key: key}, nil + } + if err != nil { + return sessionMeta{}, fmt.Errorf("memory: read meta: %w", err) + } + var meta sessionMeta + err = json.Unmarshal(data, &meta) + if err != nil { + return sessionMeta{}, fmt.Errorf("memory: decode meta: %w", err) + } + return meta, nil +} + +// writeMeta atomically writes the metadata file (temp + rename). +func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("memory: encode meta: %w", err) + } + + target := s.metaPath(key) + tmp := target + ".tmp" + + err = os.WriteFile(tmp, data, 0o644) + if err != nil { + return fmt.Errorf("memory: write meta tmp: %w", err) + } + + err = os.Rename(tmp, target) + if err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("memory: rename meta: %w", err) + } + return nil +} + +// readMessages reads all valid JSON lines from a .jsonl file. +// Malformed trailing lines (e.g. from a crash) are silently skipped. +func readMessages(path string) ([]providers.Message, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return []providers.Message{}, nil + } + if err != nil { + return nil, fmt.Errorf("memory: open jsonl: %w", err) + } + defer f.Close() + + var msgs []providers.Message + scanner := bufio.NewScanner(f) + // Allow up to 1 MB per line for messages with large content. + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var msg providers.Message + if json.Unmarshal(line, &msg) != nil { + // Corrupt line — likely a partial write from a crash. + // Skip it; this is the standard JSONL recovery pattern. + continue + } + msgs = append(msgs, msg) + } + if scanner.Err() != nil { + return nil, fmt.Errorf("memory: scan jsonl: %w", scanner.Err()) + } + + if msgs == nil { + msgs = []providers.Message{} + } + return msgs, nil +} + +func (s *JSONLStore) AddMessage( + _ context.Context, sessionKey, role, content string, +) error { + return s.addMsg(sessionKey, providers.Message{ + Role: role, + Content: content, + }) +} + +func (s *JSONLStore) AddFullMessage( + _ context.Context, sessionKey string, msg providers.Message, +) error { + return s.addMsg(sessionKey, msg) +} + +// addMsg is the shared implementation for AddMessage and AddFullMessage. +func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + // Append the message as a single JSON line. + line, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("memory: marshal message: %w", err) + } + line = append(line, '\n') + + f, err := os.OpenFile( + s.jsonlPath(sessionKey), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, + 0o644, + ) + if err != nil { + return fmt.Errorf("memory: open jsonl for append: %w", err) + } + _, writeErr := f.Write(line) + closeErr := f.Close() + if writeErr != nil { + return fmt.Errorf("memory: append message: %w", writeErr) + } + if closeErr != nil { + return fmt.Errorf("memory: close jsonl: %w", closeErr) + } + + // Update metadata. + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + now := time.Now() + if meta.Count == 0 && meta.CreatedAt.IsZero() { + meta.CreatedAt = now + } + meta.Count++ + meta.UpdatedAt = now + + return s.writeMeta(sessionKey, meta) +} + +func (s *JSONLStore) GetHistory( + _ context.Context, sessionKey string, +) ([]providers.Message, error) { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return nil, err + } + + msgs, err := readMessages(s.jsonlPath(sessionKey)) + if err != nil { + return nil, err + } + + // Apply logical truncation: skip the first meta.Skip messages. + if meta.Skip > 0 && meta.Skip < len(msgs) { + msgs = msgs[meta.Skip:] + } else if meta.Skip >= len(msgs) { + msgs = []providers.Message{} + } + + return msgs, nil +} + +func (s *JSONLStore) GetSummary( + _ context.Context, sessionKey string, +) (string, error) { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return "", err + } + return meta.Summary, nil +} + +func (s *JSONLStore) SetSummary( + _ context.Context, sessionKey, summary string, +) error { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + now := time.Now() + if meta.CreatedAt.IsZero() { + meta.CreatedAt = now + } + meta.Summary = summary + meta.UpdatedAt = now + + return s.writeMeta(sessionKey, meta) +} + +func (s *JSONLStore) TruncateHistory( + _ context.Context, sessionKey string, keepLast int, +) error { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + + // If the meta count might be stale (e.g. after a crash during + // addMsg), reconcile with the actual line count on disk. + if meta.Count == 0 { + msgs, readErr := readMessages(s.jsonlPath(sessionKey)) + if readErr != nil { + return readErr + } + meta.Count = len(msgs) + } + + if keepLast <= 0 { + meta.Skip = meta.Count + } else { + effective := meta.Count - meta.Skip + if keepLast < effective { + meta.Skip = meta.Count - keepLast + } + } + meta.UpdatedAt = time.Now() + + return s.writeMeta(sessionKey, meta) +} + +func (s *JSONLStore) SetHistory( + _ context.Context, + sessionKey string, + history []providers.Message, +) error { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + // Rewrite the JSONL file atomically (temp + rename). + target := s.jsonlPath(sessionKey) + tmp := target + ".tmp" + + f, err := os.Create(tmp) + if err != nil { + return fmt.Errorf("memory: create jsonl tmp: %w", err) + } + + for i, msg := range history { + line, marshalErr := json.Marshal(msg) + if marshalErr != nil { + f.Close() + _ = os.Remove(tmp) + return fmt.Errorf("memory: marshal message %d: %w", i, marshalErr) + } + line = append(line, '\n') + _, writeErr := f.Write(line) + if writeErr != nil { + f.Close() + _ = os.Remove(tmp) + return fmt.Errorf("memory: write message %d: %w", i, writeErr) + } + } + + err = f.Close() + if err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("memory: close jsonl tmp: %w", err) + } + + err = os.Rename(tmp, target) + if err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("memory: rename jsonl: %w", err) + } + + // Reset metadata: skip=0, count=len(history). + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + now := time.Now() + if meta.CreatedAt.IsZero() { + meta.CreatedAt = now + } + meta.Skip = 0 + meta.Count = len(history) + meta.UpdatedAt = now + + return s.writeMeta(sessionKey, meta) +} + +func (s *JSONLStore) Close() error { + return nil +} From 529622b7d3d49d068f332a3a1ecef7eee2848bf1 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Tue, 24 Feb 2026 22:22:46 +0800 Subject: [PATCH 049/132] test(memory): add unit, concurrency, and benchmark tests Cover all Store interface methods plus edge cases: - Basic roundtrip, ordering, empty session, tool calls - Logical truncation (keep last N, keep zero, keep more than exist) - SetHistory replacing all + resetting skip offset - Crash recovery with partial JSON lines - Persistence across store instances - Concurrent add+read (10 goroutines x 20 msgs) - Simulated #704 race (summarizer vs main loop) - Benchmarks for AddMessage and GetHistory (100/1000 msgs) --- pkg/memory/jsonl_test.go | 663 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 663 insertions(+) create mode 100644 pkg/memory/jsonl_test.go diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go new file mode 100644 index 000000000..57675504d --- /dev/null +++ b/pkg/memory/jsonl_test.go @@ -0,0 +1,663 @@ +package memory + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func newTestStore(t *testing.T) *JSONLStore { + t.Helper() + store, err := NewJSONLStore(t.TempDir()) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + return store +} + +func TestNewJSONLStore_CreatesDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "sessions") + store, err := NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + defer store.Close() + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if !info.IsDir() { + t.Errorf("expected directory, got file") + } +} + +func TestAddMessage_BasicRoundtrip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + err := store.AddMessage(ctx, "s1", "user", "hello") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + err = store.AddMessage(ctx, "s1", "assistant", "hi there") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "s1") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected 2 messages, got %d", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Errorf("msg[0] = %+v", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "hi there" { + t.Errorf("msg[1] = %+v", history[1]) + } +} + +func TestAddMessage_AutoCreatesSession(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Adding a message to a non-existent session should work. + err := store.AddMessage(ctx, "new-session", "user", "first message") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "new-session") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 message, got %d", len(history)) + } +} + +func TestAddFullMessage_WithToolCalls(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + msg := providers.Message{ + Role: "assistant", + Content: "Let me search that.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_abc", + Type: "function", + Function: &providers.FunctionCall{ + Name: "web_search", + Arguments: `{"q":"golang jsonl"}`, + }, + }, + }, + } + + err := store.AddFullMessage(ctx, "tc", msg) + if err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "tc") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1, got %d", len(history)) + } + if len(history[0].ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls)) + } + tc := history[0].ToolCalls[0] + if tc.ID != "call_abc" { + t.Errorf("tool call ID = %q", tc.ID) + } + if tc.Function == nil || tc.Function.Name != "web_search" { + t.Errorf("tool call function = %+v", tc.Function) + } +} + +func TestAddFullMessage_ToolCallID(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + msg := providers.Message{ + Role: "tool", + Content: "search results here", + ToolCallID: "call_abc", + } + + err := store.AddFullMessage(ctx, "tr", msg) + if err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "tr") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1, got %d", len(history)) + } + if history[0].ToolCallID != "call_abc" { + t.Errorf("ToolCallID = %q", history[0].ToolCallID) + } +} + +func TestGetHistory_EmptySession(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + history, err := store.GetHistory(ctx, "nonexistent") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if history == nil { + t.Fatal("expected non-nil empty slice") + } + if len(history) != 0 { + t.Errorf("expected 0 messages, got %d", len(history)) + } +} + +func TestGetHistory_Ordering(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 5; i++ { + err := store.AddMessage( + ctx, "order", + "user", + string(rune('a'+i)), + ) + if err != nil { + t.Fatalf("AddMessage(%d): %v", i, err) + } + } + + history, err := store.GetHistory(ctx, "order") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 5 { + t.Fatalf("expected 5, got %d", len(history)) + } + for i := 0; i < 5; i++ { + expected := string(rune('a' + i)) + if history[i].Content != expected { + t.Errorf("msg[%d].Content = %q, want %q", i, history[i].Content, expected) + } + } +} + +func TestSetSummary_GetSummary(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // No summary yet. + summary, err := store.GetSummary(ctx, "s1") + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if summary != "" { + t.Errorf("expected empty, got %q", summary) + } + + // Set a summary. + err = store.SetSummary(ctx, "s1", "talked about Go") + if err != nil { + t.Fatalf("SetSummary: %v", err) + } + + summary, err = store.GetSummary(ctx, "s1") + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if summary != "talked about Go" { + t.Errorf("summary = %q", summary) + } + + // Update summary. + err = store.SetSummary(ctx, "s1", "updated summary") + if err != nil { + t.Fatalf("SetSummary: %v", err) + } + + summary, err = store.GetSummary(ctx, "s1") + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if summary != "updated summary" { + t.Errorf("summary = %q", summary) + } +} + +func TestTruncateHistory_KeepLast(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 10; i++ { + err := store.AddMessage( + ctx, "trunc", + "user", + string(rune('a'+i)), + ) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + err := store.TruncateHistory(ctx, "trunc", 4) + if err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "trunc") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 4 { + t.Fatalf("expected 4, got %d", len(history)) + } + // Should be the last 4: g, h, i, j + if history[0].Content != "g" { + t.Errorf("first kept = %q, want 'g'", history[0].Content) + } + if history[3].Content != "j" { + t.Errorf("last kept = %q, want 'j'", history[3].Content) + } +} + +func TestTruncateHistory_KeepZero(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 5; i++ { + err := store.AddMessage(ctx, "empty", "user", "msg") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + err := store.TruncateHistory(ctx, "empty", 0) + if err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "empty") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 0 { + t.Errorf("expected 0, got %d", len(history)) + } +} + +func TestTruncateHistory_KeepMoreThanExists(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 3; i++ { + err := store.AddMessage(ctx, "few", "user", "msg") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + // Keep 100, but only 3 exist — should keep all. + err := store.TruncateHistory(ctx, "few", 100) + if err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "few") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 3 { + t.Errorf("expected 3, got %d", len(history)) + } +} + +func TestSetHistory_ReplacesAll(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Add some initial messages. + for i := 0; i < 5; i++ { + err := store.AddMessage(ctx, "replace", "user", "old") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + // Replace with new history. + newHistory := []providers.Message{ + {Role: "user", Content: "new1"}, + {Role: "assistant", Content: "new2"}, + } + err := store.SetHistory(ctx, "replace", newHistory) + if err != nil { + t.Fatalf("SetHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "replace") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected 2, got %d", len(history)) + } + if history[0].Content != "new1" || history[1].Content != "new2" { + t.Errorf("history = %+v", history) + } +} + +func TestSetHistory_ResetsSkip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Add messages and truncate. + for i := 0; i < 10; i++ { + err := store.AddMessage(ctx, "skip-reset", "user", "old") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + err := store.TruncateHistory(ctx, "skip-reset", 3) + if err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + // SetHistory should reset skip to 0. + newHistory := []providers.Message{ + {Role: "user", Content: "fresh"}, + } + err = store.SetHistory(ctx, "skip-reset", newHistory) + if err != nil { + t.Fatalf("SetHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "skip-reset") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1, got %d", len(history)) + } + if history[0].Content != "fresh" { + t.Errorf("content = %q", history[0].Content) + } +} + +func TestColonInKey(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + err := store.AddMessage(ctx, "telegram:123", "user", "hi") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "telegram:123") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1, got %d", len(history)) + } + + // Verify the file is named with underscore. + jsonlFile := filepath.Join(store.dir, "telegram_123.jsonl") + if _, statErr := os.Stat(jsonlFile); statErr != nil { + t.Errorf("expected file %s to exist: %v", jsonlFile, statErr) + } +} + +func TestCrashRecovery_PartialLine(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Write a valid message first. + err := store.AddMessage(ctx, "crash", "user", "valid") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + // Simulate a crash by appending a partial JSON line directly. + jsonlPath := store.jsonlPath("crash") + f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatalf("open for append: %v", err) + } + _, err = f.WriteString(`{"role":"user","content":"incomple`) + if err != nil { + t.Fatalf("write partial: %v", err) + } + f.Close() + + // GetHistory should return only the valid message. + history, err := store.GetHistory(ctx, "crash") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 valid message, got %d", len(history)) + } + if history[0].Content != "valid" { + t.Errorf("content = %q", history[0].Content) + } +} + +func TestPersistence_AcrossInstances(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + // Write with first instance. + store1, err := NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + err = store1.AddMessage(ctx, "persist", "user", "remember me") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + err = store1.SetSummary(ctx, "persist", "a test session") + if err != nil { + t.Fatalf("SetSummary: %v", err) + } + store1.Close() + + // Read with second instance. + store2, err := NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + defer store2.Close() + + history, err := store2.GetHistory(ctx, "persist") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 || history[0].Content != "remember me" { + t.Errorf("history = %+v", history) + } + + summary, err := store2.GetSummary(ctx, "persist") + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if summary != "a test session" { + t.Errorf("summary = %q", summary) + } +} + +func TestConcurrent_AddAndRead(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + var wg sync.WaitGroup + const goroutines = 10 + const msgsPerGoroutine = 20 + + // Concurrent writes. + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < msgsPerGoroutine; i++ { + _ = store.AddMessage(ctx, "concurrent", "user", "msg") + } + }() + } + wg.Wait() + + history, err := store.GetHistory(ctx, "concurrent") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + expected := goroutines * msgsPerGoroutine + if len(history) != expected { + t.Errorf("expected %d messages, got %d", expected, len(history)) + } +} + +func TestConcurrent_SummarizeRace(t *testing.T) { + // Simulates the #704 race: one goroutine adds messages while + // another truncates + sets summary — like summarizeSession(). + store := newTestStore(t) + ctx := context.Background() + + // Seed with some messages. + for i := 0; i < 20; i++ { + err := store.AddMessage(ctx, "race", "user", "seed") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + var wg sync.WaitGroup + + // Writer goroutine (main agent loop). + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + _ = store.AddMessage(ctx, "race", "user", "new") + } + }() + + // Summarizer goroutine (background task). + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + _ = store.SetSummary(ctx, "race", "summary") + _ = store.TruncateHistory(ctx, "race", 5) + } + }() + + wg.Wait() + + // Verify the store is still in a consistent state. + _, err := store.GetHistory(ctx, "race") + if err != nil { + t.Fatalf("GetHistory after race: %v", err) + } + _, err = store.GetSummary(ctx, "race") + if err != nil { + t.Fatalf("GetSummary after race: %v", err) + } +} + +func TestMultipleSessions_Isolation(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + err := store.AddMessage(ctx, "s1", "user", "msg for s1") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + err = store.AddMessage(ctx, "s2", "user", "msg for s2") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + h1, err := store.GetHistory(ctx, "s1") + if err != nil { + t.Fatalf("GetHistory s1: %v", err) + } + h2, err := store.GetHistory(ctx, "s2") + if err != nil { + t.Fatalf("GetHistory s2: %v", err) + } + + if len(h1) != 1 || h1[0].Content != "msg for s1" { + t.Errorf("s1 history = %+v", h1) + } + if len(h2) != 1 || h2[0].Content != "msg for s2" { + t.Errorf("s2 history = %+v", h2) + } +} + +func BenchmarkAddMessage(b *testing.B) { + dir := b.TempDir() + store, err := NewJSONLStore(dir) + if err != nil { + b.Fatalf("NewJSONLStore: %v", err) + } + defer store.Close() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = store.AddMessage(ctx, "bench", "user", "benchmark message content") + } +} + +func BenchmarkGetHistory_100(b *testing.B) { + dir := b.TempDir() + store, err := NewJSONLStore(dir) + if err != nil { + b.Fatalf("NewJSONLStore: %v", err) + } + defer store.Close() + ctx := context.Background() + + for i := 0; i < 100; i++ { + _ = store.AddMessage(ctx, "bench", "user", "message content") + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = store.GetHistory(ctx, "bench") + } +} + +func BenchmarkGetHistory_1000(b *testing.B) { + dir := b.TempDir() + store, err := NewJSONLStore(dir) + if err != nil { + b.Fatalf("NewJSONLStore: %v", err) + } + defer store.Close() + ctx := context.Background() + + for i := 0; i < 1000; i++ { + _ = store.AddMessage(ctx, "bench", "user", "message content") + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = store.GetHistory(ctx, "bench") + } +} From 903681207ba3b423ee04d6a7056738dacbf75f08 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Tue, 24 Feb 2026 22:22:58 +0800 Subject: [PATCH 050/132] feat(memory): support migration from legacy JSON sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read existing sessions/*.json files, convert to JSONL format, and rename originals to .json.migrated as backup. The migration is idempotent — second runs skip already-migrated files. Session keys are read from JSON content (not filenames) so that sanitized names like telegram_123 correctly map back to telegram:123. --- pkg/memory/migration.go | 107 ++++++++++++ pkg/memory/migration_test.go | 328 +++++++++++++++++++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 pkg/memory/migration.go create mode 100644 pkg/memory/migration_test.go diff --git a/pkg/memory/migration.go b/pkg/memory/migration.go new file mode 100644 index 000000000..5b2f69ab3 --- /dev/null +++ b/pkg/memory/migration.go @@ -0,0 +1,107 @@ +package memory + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// jsonSession mirrors pkg/session.Session for migration purposes. +type jsonSession struct { + Key string `json:"key"` + Messages []providers.Message `json:"messages"` + Summary string `json:"summary,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// MigrateFromJSON reads legacy sessions/*.json files from sessionsDir, +// writes them into the Store, and renames each migrated file to +// .json.migrated as a backup. Returns the number of sessions migrated. +// +// Files that fail to parse are logged and skipped. Already-migrated +// files (.json.migrated) are ignored, making the function idempotent. +func MigrateFromJSON( + ctx context.Context, sessionsDir string, store Store, +) (int, error) { + entries, err := os.ReadDir(sessionsDir) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("memory: read sessions dir: %w", err) + } + + migrated := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + // Skip already-migrated files. + if strings.HasSuffix(name, ".migrated") { + continue + } + + srcPath := filepath.Join(sessionsDir, name) + + data, readErr := os.ReadFile(srcPath) + if readErr != nil { + log.Printf("memory: migrate: skip %s: %v", name, readErr) + continue + } + + var sess jsonSession + if parseErr := json.Unmarshal(data, &sess); parseErr != nil { + log.Printf("memory: migrate: skip %s: %v", name, parseErr) + continue + } + + // Use the key from the JSON content, not the filename. + // Filenames are sanitized (":" → "_") but keys are not. + key := sess.Key + if key == "" { + key = strings.TrimSuffix(name, ".json") + } + + for _, msg := range sess.Messages { + addErr := store.AddFullMessage(ctx, key, msg) + if addErr != nil { + return migrated, fmt.Errorf( + "memory: migrate %s: add message: %w", + name, addErr, + ) + } + } + + if sess.Summary != "" { + sumErr := store.SetSummary(ctx, key, sess.Summary) + if sumErr != nil { + return migrated, fmt.Errorf( + "memory: migrate %s: set summary: %w", + name, sumErr, + ) + } + } + + // Rename to .migrated as backup (not delete). + renameErr := os.Rename(srcPath, srcPath+".migrated") + if renameErr != nil { + log.Printf("memory: migrate: rename %s: %v", name, renameErr) + } + + migrated++ + } + + return migrated, nil +} diff --git a/pkg/memory/migration_test.go b/pkg/memory/migration_test.go new file mode 100644 index 000000000..bf16c32f8 --- /dev/null +++ b/pkg/memory/migration_test.go @@ -0,0 +1,328 @@ +package memory + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func writeJSONSession( + t *testing.T, dir string, filename string, sess jsonSession, +) { + t.Helper() + data, err := json.MarshalIndent(sess, "", " ") + if err != nil { + t.Fatalf("marshal session: %v", err) + } + err = os.WriteFile(filepath.Join(dir, filename), data, 0o644) + if err != nil { + t.Fatalf("write session file: %v", err) + } +} + +func TestMigrateFromJSON_Basic(t *testing.T) { + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + writeJSONSession(t, sessionsDir, "test.json", jsonSession{ + Key: "test", + Messages: []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi"}, + }, + Summary: "A greeting.", + Created: time.Now(), + Updated: time.Now(), + }) + + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 1 { + t.Errorf("expected 1 migrated, got %d", count) + } + + history, err := store.GetHistory(ctx, "test") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected 2 messages, got %d", len(history)) + } + if history[0].Content != "hello" || history[1].Content != "hi" { + t.Errorf("unexpected messages: %+v", history) + } + + summary, err := store.GetSummary(ctx, "test") + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if summary != "A greeting." { + t.Errorf("summary = %q", summary) + } +} + +func TestMigrateFromJSON_WithToolCalls(t *testing.T) { + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + writeJSONSession(t, sessionsDir, "tools.json", jsonSession{ + Key: "tools", + Messages: []providers.Message{ + { + Role: "assistant", + Content: "Searching...", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "web_search", + Arguments: `{"q":"test"}`, + }, + }, + }, + }, + { + Role: "tool", + Content: "result", + ToolCallID: "call_1", + }, + }, + Created: time.Now(), + Updated: time.Now(), + }) + + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 1 { + t.Errorf("expected 1, got %d", count) + } + + history, err := store.GetHistory(ctx, "tools") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected 2 messages, got %d", len(history)) + } + if len(history[0].ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls)) + } + if history[0].ToolCalls[0].Function.Name != "web_search" { + t.Errorf("function = %q", history[0].ToolCalls[0].Function.Name) + } + if history[1].ToolCallID != "call_1" { + t.Errorf("ToolCallID = %q", history[1].ToolCallID) + } +} + +func TestMigrateFromJSON_MultipleFiles(t *testing.T) { + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 3; i++ { + key := string(rune('a' + i)) + writeJSONSession(t, sessionsDir, key+".json", jsonSession{ + Key: key, + Messages: []providers.Message{{Role: "user", Content: "msg " + key}}, + Created: time.Now(), + Updated: time.Now(), + }) + } + + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 3 { + t.Errorf("expected 3, got %d", count) + } + + for i := 0; i < 3; i++ { + key := string(rune('a' + i)) + history, histErr := store.GetHistory(ctx, key) + if histErr != nil { + t.Fatalf("GetHistory(%q): %v", key, histErr) + } + if len(history) != 1 { + t.Errorf("session %q: expected 1 msg, got %d", key, len(history)) + } + } +} + +func TestMigrateFromJSON_InvalidJSON(t *testing.T) { + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + // One valid, one invalid. + writeJSONSession(t, sessionsDir, "good.json", jsonSession{ + Key: "good", + Messages: []providers.Message{{Role: "user", Content: "ok"}}, + Created: time.Now(), + Updated: time.Now(), + }) + err := os.WriteFile( + filepath.Join(sessionsDir, "bad.json"), + []byte("{invalid json"), + 0o644, + ) + if err != nil { + t.Fatalf("write bad file: %v", err) + } + + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 1 { + t.Errorf("expected 1 (bad file skipped), got %d", count) + } + + history, err := store.GetHistory(ctx, "good") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Errorf("expected 1 message, got %d", len(history)) + } +} + +func TestMigrateFromJSON_RenamesFiles(t *testing.T) { + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + writeJSONSession(t, sessionsDir, "rename.json", jsonSession{ + Key: "rename", + Messages: []providers.Message{{Role: "user", Content: "hi"}}, + Created: time.Now(), + Updated: time.Now(), + }) + + _, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + + // Original .json should not exist. + _, statErr := os.Stat(filepath.Join(sessionsDir, "rename.json")) + if !os.IsNotExist(statErr) { + t.Error("rename.json should have been renamed") + } + // .json.migrated should exist. + _, statErr = os.Stat( + filepath.Join(sessionsDir, "rename.json.migrated"), + ) + if statErr != nil { + t.Errorf("rename.json.migrated should exist: %v", statErr) + } +} + +func TestMigrateFromJSON_Idempotent(t *testing.T) { + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + writeJSONSession(t, sessionsDir, "idem.json", jsonSession{ + Key: "idem", + Messages: []providers.Message{{Role: "user", Content: "once"}}, + Created: time.Now(), + Updated: time.Now(), + }) + + count1, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("first migration: %v", err) + } + if count1 != 1 { + t.Errorf("first run: expected 1, got %d", count1) + } + + // Second run should find only .migrated files, skip them. + count2, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("second migration: %v", err) + } + if count2 != 0 { + t.Errorf("second run: expected 0, got %d", count2) + } + + history, err := store.GetHistory(ctx, "idem") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Errorf("expected 1 message, got %d", len(history)) + } +} + +func TestMigrateFromJSON_ColonInKey(t *testing.T) { + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + // File is named telegram_123 (sanitized), but the key inside is telegram:123. + writeJSONSession(t, sessionsDir, "telegram_123.json", jsonSession{ + Key: "telegram:123", + Messages: []providers.Message{{Role: "user", Content: "from telegram"}}, + Created: time.Now(), + Updated: time.Now(), + }) + + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 1 { + t.Errorf("expected 1, got %d", count) + } + + // Accessible via the original key "telegram:123". + history, err := store.GetHistory(ctx, "telegram:123") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 message, got %d", len(history)) + } + if history[0].Content != "from telegram" { + t.Errorf("content = %q", history[0].Content) + } + + // In the file-based store, "telegram:123" and "telegram_123" both + // sanitize to the same filename, so they share storage. This is + // expected — the colon-to-underscore mapping is a one-way function. + history2, err := store.GetHistory(ctx, "telegram_123") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history2) != 1 { + t.Errorf("expected 1 (same file), got %d", len(history2)) + } +} + +func TestMigrateFromJSON_NonexistentDir(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + count, err := MigrateFromJSON(ctx, "/nonexistent/path", store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 0 { + t.Errorf("expected 0, got %d", count) + } +} From b464687e2fc0578eb23f3e9be4aa20849d5bcaa4 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Thu, 26 Feb 2026 08:42:35 +0800 Subject: [PATCH 051/132] feat(memory): add Compact method for physical JSONL compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address file growth concern from #711 review: logical truncation via skip offset is fast but leaves dead lines on disk indefinitely. Compact() rewrites the JSONL file keeping only active messages, using the same temp+rename pattern for crash safety. No-op when skip == 0. The caller (lifecycle manager or agent loop) decides when to trigger compaction — e.g. when skipped lines exceed active lines. --- pkg/memory/jsonl.go | 87 ++++++++++++++++++++++------ pkg/memory/jsonl_test.go | 121 +++++++++++++++++++++++++++++++++++++++ pkg/memory/store.go | 4 ++ 3 files changed, 195 insertions(+), 17 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 266f453d9..be71396ca 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -328,7 +328,74 @@ func (s *JSONLStore) SetHistory( l.Lock() defer l.Unlock() - // Rewrite the JSONL file atomically (temp + rename). + err := s.rewriteJSONL(sessionKey, history) + if err != nil { + return err + } + + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + now := time.Now() + if meta.CreatedAt.IsZero() { + meta.CreatedAt = now + } + meta.Skip = 0 + meta.Count = len(history) + meta.UpdatedAt = now + + return s.writeMeta(sessionKey, meta) +} + +// Compact physically rewrites the JSONL file, dropping all logically +// skipped lines. This reclaims disk space that accumulates after +// repeated TruncateHistory calls. +// +// It is safe to call at any time; if there is nothing to compact +// (skip == 0) the method returns immediately. +func (s *JSONLStore) Compact( + _ context.Context, sessionKey string, +) error { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + if meta.Skip == 0 { + return nil + } + + all, err := readMessages(s.jsonlPath(sessionKey)) + if err != nil { + return err + } + + // Keep only the active (non-skipped) messages. + var active []providers.Message + if meta.Skip < len(all) { + active = all[meta.Skip:] + } + + err = s.rewriteJSONL(sessionKey, active) + if err != nil { + return err + } + + meta.Skip = 0 + meta.Count = len(active) + meta.UpdatedAt = time.Now() + + return s.writeMeta(sessionKey, meta) +} + +// rewriteJSONL atomically replaces the JSONL file with the given messages. +func (s *JSONLStore) rewriteJSONL( + sessionKey string, msgs []providers.Message, +) error { target := s.jsonlPath(sessionKey) tmp := target + ".tmp" @@ -337,7 +404,7 @@ func (s *JSONLStore) SetHistory( return fmt.Errorf("memory: create jsonl tmp: %w", err) } - for i, msg := range history { + for i, msg := range msgs { line, marshalErr := json.Marshal(msg) if marshalErr != nil { f.Close() @@ -364,21 +431,7 @@ func (s *JSONLStore) SetHistory( _ = os.Remove(tmp) return fmt.Errorf("memory: rename jsonl: %w", err) } - - // Reset metadata: skip=0, count=len(history). - meta, err := s.readMeta(sessionKey) - if err != nil { - return err - } - now := time.Now() - if meta.CreatedAt.IsZero() { - meta.CreatedAt = now - } - meta.Skip = 0 - meta.Count = len(history) - meta.UpdatedAt = now - - return s.writeMeta(sessionKey, meta) + return nil } func (s *JSONLStore) Close() error { diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 57675504d..e3b53bfde 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -423,6 +423,127 @@ func TestColonInKey(t *testing.T) { } } +func TestCompact_RemovesSkippedMessages(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Write 10 messages, then truncate to keep last 3. + for i := 0; i < 10; i++ { + err := store.AddMessage(ctx, "compact", "user", string(rune('a'+i))) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + err := store.TruncateHistory(ctx, "compact", 3) + if err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + // Before compact: file still has 10 lines. + allOnDisk, err := readMessages(store.jsonlPath("compact")) + if err != nil { + t.Fatalf("readMessages: %v", err) + } + if len(allOnDisk) != 10 { + t.Fatalf("before compact: expected 10 on disk, got %d", len(allOnDisk)) + } + + // Compact. + err = store.Compact(ctx, "compact") + if err != nil { + t.Fatalf("Compact: %v", err) + } + + // After compact: file should have only 3 lines. + allOnDisk, err = readMessages(store.jsonlPath("compact")) + if err != nil { + t.Fatalf("readMessages: %v", err) + } + if len(allOnDisk) != 3 { + t.Fatalf("after compact: expected 3 on disk, got %d", len(allOnDisk)) + } + + // GetHistory should still return the same 3 messages. + history, err := store.GetHistory(ctx, "compact") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 3 { + t.Fatalf("expected 3, got %d", len(history)) + } + if history[0].Content != "h" || history[2].Content != "j" { + t.Errorf("wrong content: %+v", history) + } +} + +func TestCompact_NoOpWhenNoSkip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 5; i++ { + err := store.AddMessage(ctx, "noop", "user", "msg") + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + // Compact without prior truncation — should be a no-op. + err := store.Compact(ctx, "noop") + if err != nil { + t.Fatalf("Compact: %v", err) + } + + history, err := store.GetHistory(ctx, "noop") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 5 { + t.Errorf("expected 5, got %d", len(history)) + } +} + +func TestCompact_ThenAppend(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 8; i++ { + err := store.AddMessage(ctx, "cap", "user", string(rune('a'+i))) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + err := store.TruncateHistory(ctx, "cap", 2) + if err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + err = store.Compact(ctx, "cap") + if err != nil { + t.Fatalf("Compact: %v", err) + } + + // Append after compaction should work correctly. + err = store.AddMessage(ctx, "cap", "user", "new") + if err != nil { + t.Fatalf("AddMessage after compact: %v", err) + } + + history, err := store.GetHistory(ctx, "cap") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 3 { + t.Fatalf("expected 3, got %d", len(history)) + } + // g, h (kept from truncation), new (appended after compaction). + if history[0].Content != "g" { + t.Errorf("first = %q, want 'g'", history[0].Content) + } + if history[2].Content != "new" { + t.Errorf("last = %q, want 'new'", history[2].Content) + } +} + func TestCrashRecovery_PartialLine(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/pkg/memory/store.go b/pkg/memory/store.go index 6887ec26e..b6e11707d 100644 --- a/pkg/memory/store.go +++ b/pkg/memory/store.go @@ -33,6 +33,10 @@ type Store interface { // SetHistory replaces all messages in a session with the provided history. SetHistory(ctx context.Context, sessionKey string, history []providers.Message) error + // Compact reclaims storage by physically removing logically truncated + // data. Backends that do not accumulate dead data may return nil. + Compact(ctx context.Context, sessionKey string) error + // Close releases any resources held by the store. Close() error } From 5d73ee2d9a72c27233cbb27a305d128130228944 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Thu, 26 Feb 2026 14:31:02 +0800 Subject: [PATCH 052/132] refactor(memory): use sync.Map for session locks and skip-scan in readMessages Address review feedback from @Zhaoyikaiii: - Replace map[string]*sync.Mutex + separate mu with sync.Map.LoadOrStore for simpler, lock-free session lock management. - Add skip parameter to readMessages so callers (GetHistory, Compact) can skip truncated lines without paying the json.Unmarshal cost. - Add countLines helper for TruncateHistory's count reconciliation, avoiding full deserialization when only the line count is needed. --- pkg/memory/jsonl.go | 86 ++++++++++++++++++++++------------------ pkg/memory/jsonl_test.go | 4 +- 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index be71396ca..eda9563fe 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -36,10 +36,8 @@ type sessionMeta struct { // GetHistory ignores lines before that offset. This keeps all writes // append-only, which is both fast and crash-safe. type JSONLStore struct { - dir string - - mu sync.Mutex - locks map[string]*sync.Mutex + dir string + locks sync.Map // map[string]*sync.Mutex, one per session } // NewJSONLStore creates a new JSONL-backed store rooted at dir. @@ -48,23 +46,13 @@ func NewJSONLStore(dir string) (*JSONLStore, error) { if err != nil { return nil, fmt.Errorf("memory: create directory: %w", err) } - return &JSONLStore{ - dir: dir, - locks: make(map[string]*sync.Mutex), - }, nil + return &JSONLStore{dir: dir}, nil } // sessionLock returns (or creates) a per-session mutex. func (s *JSONLStore) sessionLock(key string) *sync.Mutex { - s.mu.Lock() - defer s.mu.Unlock() - - l, ok := s.locks[key] - if !ok { - l = &sync.Mutex{} - s.locks[key] = l - } - return l + v, _ := s.locks.LoadOrStore(key, &sync.Mutex{}) + return v.(*sync.Mutex) } func (s *JSONLStore) jsonlPath(key string) string { @@ -122,9 +110,11 @@ func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { return nil } -// readMessages reads all valid JSON lines from a .jsonl file. +// readMessages reads valid JSON lines from a .jsonl file, skipping +// the first `skip` lines without unmarshaling them. This avoids the +// cost of json.Unmarshal on logically truncated messages. // Malformed trailing lines (e.g. from a crash) are silently skipped. -func readMessages(path string) ([]providers.Message, error) { +func readMessages(path string, skip int) ([]providers.Message, error) { f, err := os.Open(path) if os.IsNotExist(err) { return []providers.Message{}, nil @@ -139,11 +129,16 @@ func readMessages(path string) ([]providers.Message, error) { // Allow up to 1 MB per line for messages with large content. scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + lineNum := 0 for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } + lineNum++ + if lineNum <= skip { + continue + } var msg providers.Message if json.Unmarshal(line, &msg) != nil { // Corrupt line — likely a partial write from a crash. @@ -162,6 +157,30 @@ func readMessages(path string) ([]providers.Message, error) { return msgs, nil } +// countLines counts the total number of non-empty lines in a .jsonl file. +// Used by TruncateHistory to reconcile a stale meta.Count without +// the overhead of unmarshaling every message. +func countLines(path string) (int, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("memory: open jsonl: %w", err) + } + defer f.Close() + + n := 0 + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + if len(scanner.Bytes()) > 0 { + n++ + } + } + return n, scanner.Err() +} + func (s *JSONLStore) AddMessage( _ context.Context, sessionKey, role, content string, ) error { @@ -234,18 +253,13 @@ func (s *JSONLStore) GetHistory( return nil, err } - msgs, err := readMessages(s.jsonlPath(sessionKey)) + // Pass meta.Skip so readMessages skips those lines without + // unmarshaling them — avoids wasted CPU on truncated messages. + msgs, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) if err != nil { return nil, err } - // Apply logical truncation: skip the first meta.Skip messages. - if meta.Skip > 0 && meta.Skip < len(msgs) { - msgs = msgs[meta.Skip:] - } else if meta.Skip >= len(msgs) { - msgs = []providers.Message{} - } - return msgs, nil } @@ -299,11 +313,11 @@ func (s *JSONLStore) TruncateHistory( // If the meta count might be stale (e.g. after a crash during // addMsg), reconcile with the actual line count on disk. if meta.Count == 0 { - msgs, readErr := readMessages(s.jsonlPath(sessionKey)) - if readErr != nil { - return readErr + n, countErr := countLines(s.jsonlPath(sessionKey)) + if countErr != nil { + return countErr } - meta.Count = len(msgs) + meta.Count = n } if keepLast <= 0 { @@ -369,17 +383,13 @@ func (s *JSONLStore) Compact( return nil } - all, err := readMessages(s.jsonlPath(sessionKey)) + // Read only the active messages, skipping truncated lines + // without unmarshaling them. + active, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) if err != nil { return err } - // Keep only the active (non-skipped) messages. - var active []providers.Message - if meta.Skip < len(all) { - active = all[meta.Skip:] - } - err = s.rewriteJSONL(sessionKey, active) if err != nil { return err diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index e3b53bfde..779cab041 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -440,7 +440,7 @@ func TestCompact_RemovesSkippedMessages(t *testing.T) { } // Before compact: file still has 10 lines. - allOnDisk, err := readMessages(store.jsonlPath("compact")) + allOnDisk, err := readMessages(store.jsonlPath("compact"), 0) if err != nil { t.Fatalf("readMessages: %v", err) } @@ -455,7 +455,7 @@ func TestCompact_RemovesSkippedMessages(t *testing.T) { } // After compact: file should have only 3 lines. - allOnDisk, err = readMessages(store.jsonlPath("compact")) + allOnDisk, err = readMessages(store.jsonlPath("compact"), 0) if err != nil { t.Fatalf("readMessages: %v", err) } From d55e5540af6de070d3b1b9750e825c3054c6a30f Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Thu, 26 Feb 2026 15:35:04 +0800 Subject: [PATCH 053/132] fix(memory): bound lock memory and increase scanner buffer Address feedback from @yinwm for long-running daemon use: - Replace sync.Map with a fixed-size sharded lock array (64 mutexes). Keys are mapped via FNV hash, so memory is O(1) regardless of how many sessions are created over the process lifetime. - Increase scanner buffer cap from 1 MB to 10 MB. Tool results (read_file on large files, web search responses) can easily exceed 1 MB. The scanner still starts at 64 KB and only grows as needed. --- pkg/memory/jsonl.go | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index eda9563fe..13f450835 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "hash/fnv" "os" "path/filepath" "strings" @@ -14,6 +15,20 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +const ( + // numLockShards is the fixed number of mutexes used to serialize + // per-session access. Using a sharded array instead of a map keeps + // memory bounded regardless of how many sessions are created over + // the lifetime of the process — important for a long-running daemon. + numLockShards = 64 + + // maxLineSize is the maximum size of a single JSON line in a .jsonl + // file. Tool results (read_file, web search, etc.) can be large, so + // we set a generous limit. The scanner starts at 64 KB and grows + // only as needed up to this cap. + maxLineSize = 10 * 1024 * 1024 // 10 MB +) + // sessionMeta holds per-session metadata stored in a .meta.json file. type sessionMeta struct { Key string `json:"key"` @@ -37,7 +52,7 @@ type sessionMeta struct { // append-only, which is both fast and crash-safe. type JSONLStore struct { dir string - locks sync.Map // map[string]*sync.Mutex, one per session + locks [numLockShards]sync.Mutex } // NewJSONLStore creates a new JSONL-backed store rooted at dir. @@ -49,10 +64,13 @@ func NewJSONLStore(dir string) (*JSONLStore, error) { return &JSONLStore{dir: dir}, nil } -// sessionLock returns (or creates) a per-session mutex. +// sessionLock returns a mutex for the given session key. +// Keys are mapped to a fixed pool of shards via FNV hash, so +// memory usage is O(1) regardless of total session count. func (s *JSONLStore) sessionLock(key string) *sync.Mutex { - v, _ := s.locks.LoadOrStore(key, &sync.Mutex{}) - return v.(*sync.Mutex) + h := fnv.New32a() + h.Write([]byte(key)) + return &s.locks[h.Sum32()%numLockShards] } func (s *JSONLStore) jsonlPath(key string) string { @@ -126,8 +144,8 @@ func readMessages(path string, skip int) ([]providers.Message, error) { var msgs []providers.Message scanner := bufio.NewScanner(f) - // Allow up to 1 MB per line for messages with large content. - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + // Allow large lines for tool results (read_file, web search, etc.). + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) lineNum := 0 for scanner.Scan() { @@ -172,7 +190,7 @@ func countLines(path string) (int, error) { n := 0 scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) for scanner.Scan() { if len(scanner.Bytes()) > 0 { n++ From 1f0b85280a5d93d01be4c7b76e72577ccbda515b Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Thu, 26 Feb 2026 16:12:34 +0800 Subject: [PATCH 054/132] fix(memory): always reconcile line count in TruncateHistory A crash between the JSONL append and the meta update in addMsg can leave meta.Count stale (e.g. file has 101 lines but meta says 100). The previous code only reconciled when Count==0, so a nonzero stale count was silently trusted, causing keepLast/skip to be calculated against the wrong total. Now TruncateHistory always counts the actual lines on disk. This is cheap (scan without unmarshal) and TruncateHistory is not a hot path. --- pkg/memory/jsonl.go | 17 +++++++------- pkg/memory/jsonl_test.go | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 13f450835..6e6722b96 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -328,15 +328,16 @@ func (s *JSONLStore) TruncateHistory( return err } - // If the meta count might be stale (e.g. after a crash during - // addMsg), reconcile with the actual line count on disk. - if meta.Count == 0 { - n, countErr := countLines(s.jsonlPath(sessionKey)) - if countErr != nil { - return countErr - } - meta.Count = n + // Always reconcile meta.Count with the actual line count on disk. + // A crash between the JSONL append and the meta update in addMsg + // leaves meta.Count stale (e.g. file has 101 lines but meta says + // 100). Counting lines is cheap — no unmarshal, just a scan — and + // TruncateHistory is not a hot path, so always re-count. + n, countErr := countLines(s.jsonlPath(sessionKey)) + if countErr != nil { + return countErr } + meta.Count = n if keepLast <= 0 { meta.Skip = meta.Count diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 779cab041..356ff14ff 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -544,6 +544,57 @@ func TestCompact_ThenAppend(t *testing.T) { } } +func TestTruncateHistory_StaleMetaCount(t *testing.T) { + // Simulates a crash between JSONL append and meta update in addMsg: + // file has N+1 lines but meta.Count is still N. TruncateHistory must + // reconcile with the real line count so that keepLast is accurate. + store := newTestStore(t) + ctx := context.Background() + + // Write 10 messages normally (meta.Count = 10). + for i := 0; i < 10; i++ { + err := store.AddMessage(ctx, "stale", "user", string(rune('a'+i))) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + // Simulate crash: append a line to JSONL but do NOT update meta. + // This leaves meta.Count = 10 while the file has 11 lines. + jsonlPath := store.jsonlPath("stale") + f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatalf("open for append: %v", err) + } + _, err = f.WriteString(`{"role":"user","content":"orphan"}` + "\n") + if err != nil { + t.Fatalf("write orphan: %v", err) + } + f.Close() + + // TruncateHistory(keepLast=4) should keep the last 4 of 11 lines, + // not the last 4 of 10. + err = store.TruncateHistory(ctx, "stale", 4) + if err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "stale") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 4 { + t.Fatalf("expected 4, got %d", len(history)) + } + // Last 4 of [a,b,c,d,e,f,g,h,i,j,orphan] = [h,i,j,orphan] + if history[0].Content != "h" { + t.Errorf("first kept = %q, want 'h'", history[0].Content) + } + if history[3].Content != "orphan" { + t.Errorf("last kept = %q, want 'orphan'", history[3].Content) + } +} + func TestCrashRecovery_PartialLine(t *testing.T) { store := newTestStore(t) ctx := context.Background() From 9c72317b9b497671ebe0de8e38993f638e5fa056 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Thu, 26 Feb 2026 16:13:57 +0800 Subject: [PATCH 055/132] fix(memory): write meta before JSONL rewrite for crash safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In SetHistory and Compact, the JSONL file was rewritten before updating the meta file. If the process crashed between the two writes, the meta still had a large Skip value pointing past the now-shorter JSONL file, causing GetHistory to return empty — effectively data loss. Reverse the order: write meta (with Skip=0) first, then rewrite JSONL. On crash between the two writes, the old uncompacted file is still intact and GetHistory reads from line 1, returning stale-but-complete data. The next operation self-corrects. --- pkg/memory/jsonl.go | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 6e6722b96..222d91f02 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -361,11 +361,6 @@ func (s *JSONLStore) SetHistory( l.Lock() defer l.Unlock() - err := s.rewriteJSONL(sessionKey, history) - if err != nil { - return err - } - meta, err := s.readMeta(sessionKey) if err != nil { return err @@ -378,7 +373,16 @@ func (s *JSONLStore) SetHistory( meta.Count = len(history) meta.UpdatedAt = now - return s.writeMeta(sessionKey, meta) + // Write meta BEFORE rewriting the JSONL file. If we crash between + // the two writes, meta has Skip=0 and the old file is still intact, + // so GetHistory reads from line 1 — returning "too many" messages + // rather than losing data. The next SetHistory call corrects this. + err = s.writeMeta(sessionKey, meta) + if err != nil { + return err + } + + return s.rewriteJSONL(sessionKey, history) } // Compact physically rewrites the JSONL file, dropping all logically @@ -409,16 +413,21 @@ func (s *JSONLStore) Compact( return err } - err = s.rewriteJSONL(sessionKey, active) - if err != nil { - return err - } - + // Write meta BEFORE rewriting the JSONL file. If the process + // crashes between the two writes, meta has Skip=0 and the old + // (uncompacted) file is still intact, so GetHistory reads from + // line 1 — returning previously-truncated messages rather than + // losing data. The next Compact or TruncateHistory corrects this. meta.Skip = 0 meta.Count = len(active) meta.UpdatedAt = time.Now() - return s.writeMeta(sessionKey, meta) + err = s.writeMeta(sessionKey, meta) + if err != nil { + return err + } + + return s.rewriteJSONL(sessionKey, active) } // rewriteJSONL atomically replaces the JSONL file with the given messages. From e810331dd8d8875d440ffe2cff6d8f530a2b13b2 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Thu, 26 Feb 2026 16:15:11 +0800 Subject: [PATCH 056/132] fix(memory): use SetHistory in migration for crash idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MigrateFromJSON previously called AddFullMessage in a loop, then renamed the .json file to .json.migrated. If the process crashed after appending some messages but before the rename, a retry would re-read the same .json and append all messages again — duplicating whatever was written before the crash. Switch to SetHistory which atomically replaces the session contents. A retry after crash overwrites the partial data instead of appending. --- pkg/memory/migration.go | 21 +++++++------- pkg/memory/migration_test.go | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/pkg/memory/migration.go b/pkg/memory/migration.go index 5b2f69ab3..c9d5176ab 100644 --- a/pkg/memory/migration.go +++ b/pkg/memory/migration.go @@ -74,19 +74,20 @@ func MigrateFromJSON( key = strings.TrimSuffix(name, ".json") } - for _, msg := range sess.Messages { - addErr := store.AddFullMessage(ctx, key, msg) - if addErr != nil { - return migrated, fmt.Errorf( - "memory: migrate %s: add message: %w", - name, addErr, - ) - } + // Use SetHistory (atomic replace) instead of per-message + // AddFullMessage. This makes migration idempotent: if the + // process crashes after writing messages but before the + // rename below, a retry replaces the partial data cleanly + // instead of duplicating messages. + if setErr := store.SetHistory(ctx, key, sess.Messages); setErr != nil { + return migrated, fmt.Errorf( + "memory: migrate %s: set history: %w", + name, setErr, + ) } if sess.Summary != "" { - sumErr := store.SetSummary(ctx, key, sess.Summary) - if sumErr != nil { + if sumErr := store.SetSummary(ctx, key, sess.Summary); sumErr != nil { return migrated, fmt.Errorf( "memory: migrate %s: set summary: %w", name, sumErr, diff --git a/pkg/memory/migration_test.go b/pkg/memory/migration_test.go index bf16c32f8..3170758b7 100644 --- a/pkg/memory/migration_test.go +++ b/pkg/memory/migration_test.go @@ -314,6 +314,62 @@ func TestMigrateFromJSON_ColonInKey(t *testing.T) { } } +func TestMigrateFromJSON_RetryAfterCrash(t *testing.T) { + // Simulates a crash during migration: first run writes messages + // but doesn't rename the .json file. Second run must replace + // (not duplicate) the messages thanks to SetHistory semantics. + sessionsDir := t.TempDir() + store := newTestStore(t) + ctx := context.Background() + + writeJSONSession(t, sessionsDir, "retry.json", jsonSession{ + Key: "retry", + Messages: []providers.Message{ + {Role: "user", Content: "one"}, + {Role: "assistant", Content: "two"}, + }, + Created: time.Now(), + Updated: time.Now(), + }) + + // First migration succeeds — writes messages and renames file. + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("first migration: %v", err) + } + if count != 1 { + t.Fatalf("expected 1, got %d", count) + } + + // Simulate "crash before rename": restore the .json file. + src := filepath.Join(sessionsDir, "retry.json.migrated") + dst := filepath.Join(sessionsDir, "retry.json") + if renameErr := os.Rename(src, dst); renameErr != nil { + t.Fatalf("restore .json: %v", renameErr) + } + + // Second migration should re-import without duplicating messages. + count, err = MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("second migration: %v", err) + } + if count != 1 { + t.Fatalf("expected 1, got %d", count) + } + + history, err := store.GetHistory(ctx, "retry") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + // Must be exactly 2 messages (not 4 from duplication). + if len(history) != 2 { + t.Fatalf("expected 2 messages (no duplicates), got %d", len(history)) + } + if history[0].Content != "one" || history[1].Content != "two" { + t.Errorf("unexpected messages: %+v", history) + } +} + func TestMigrateFromJSON_NonexistentDir(t *testing.T) { store := newTestStore(t) ctx := context.Background() From b5a4bb28b6f74de5df38143aeea7cfbecdaa9614 Mon Sep 17 00:00:00 2001 From: nayihz Date: Fri, 27 Feb 2026 14:35:23 +0800 Subject: [PATCH 057/132] feat(discord): add proxy support and tests --- config/config.example.json | 1 + pkg/channels/discord.go | 41 ++++++++++++++++ pkg/channels/discord_test.go | 94 ++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 1 + pkg/utils/media.go | 23 +++++++-- 5 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 pkg/channels/discord_test.go diff --git a/config/config.example.json b/config/config.example.json index 9575039f8..56684b259 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -57,6 +57,7 @@ "discord": { "enabled": false, "token": "YOUR_DISCORD_BOT_TOKEN", + "proxy": "", "allow_from": [], "mention_only": false }, diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index f6faa3373..8fc2514f9 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -3,12 +3,15 @@ package channels import ( "context" "fmt" + "net/http" + "net/url" "os" "strings" "sync" "time" "github.com/bwmarrin/discordgo" + "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" @@ -39,6 +42,10 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC return nil, fmt.Errorf("failed to create discord session: %w", err) } + if err := applyDiscordProxy(session, cfg.Proxy); err != nil { + return nil, err + } + base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom) return &DiscordChannel{ @@ -357,9 +364,43 @@ func (c *DiscordChannel) stopTyping(chatID string) { func (c *DiscordChannel) downloadAttachment(url, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "discord", + ProxyURL: c.config.Proxy, }) } +func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error { + var proxyFunc func(*http.Request) (*url.URL, error) + if proxyAddr != "" { + proxyURL, err := url.Parse(proxyAddr) + if err != nil { + return fmt.Errorf("invalid discord proxy URL %q: %w", proxyAddr, err) + } + proxyFunc = http.ProxyURL(proxyURL) + } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" { + proxyFunc = http.ProxyFromEnvironment + } + + if proxyFunc == nil { + return nil + } + + transport := &http.Transport{Proxy: proxyFunc} + session.Client = &http.Client{ + Timeout: 20 * time.Second, + Transport: transport, + } + + if session.Dialer != nil { + dialerCopy := *session.Dialer + dialerCopy.Proxy = proxyFunc + session.Dialer = &dialerCopy + } else { + session.Dialer = &websocket.Dialer{Proxy: proxyFunc} + } + + return nil +} + // stripBotMention removes the bot mention from the message content. // Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname). func (c *DiscordChannel) stripBotMention(text string) string { diff --git a/pkg/channels/discord_test.go b/pkg/channels/discord_test.go new file mode 100644 index 000000000..030b6cff6 --- /dev/null +++ b/pkg/channels/discord_test.go @@ -0,0 +1,94 @@ +//go:build discord_proxy +// +build discord_proxy + +package channels + +import ( + "net/http" + "net/url" + "testing" + + "github.com/bwmarrin/discordgo" +) + +func TestApplyDiscordProxy_CustomProxy(t *testing.T) { + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + + if err := applyDiscordProxy(session, "http://127.0.0.1:7890"); err != nil { + t.Fatalf("applyDiscordProxy() error: %v", err) + } + + req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil) + if err != nil { + t.Fatalf("http.NewRequest() error: %v", err) + } + + restProxy := session.Client.Transport.(*http.Transport).Proxy + restProxyURL, err := restProxy(req) + if err != nil { + t.Fatalf("rest proxy func error: %v", err) + } + if got, want := restProxyURL.String(), "http://127.0.0.1:7890"; got != want { + t.Fatalf("REST proxy = %q, want %q", got, want) + } + + wsProxyURL, err := session.Dialer.Proxy(req) + if err != nil { + t.Fatalf("ws proxy func error: %v", err) + } + if got, want := wsProxyURL.String(), "http://127.0.0.1:7890"; got != want { + t.Fatalf("WS proxy = %q, want %q", got, want) + } +} + +func TestApplyDiscordProxy_FromEnvironment(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888") + t.Setenv("http_proxy", "http://127.0.0.1:8888") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") + t.Setenv("https_proxy", "http://127.0.0.1:8888") + t.Setenv("ALL_PROXY", "") + t.Setenv("all_proxy", "") + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + + if err := applyDiscordProxy(session, ""); err != nil { + t.Fatalf("applyDiscordProxy() error: %v", err) + } + + req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil) + if err != nil { + t.Fatalf("http.NewRequest() error: %v", err) + } + + gotURL, err := session.Dialer.Proxy(req) + if err != nil { + t.Fatalf("ws proxy func error: %v", err) + } + + wantURL, err := url.Parse("http://127.0.0.1:8888") + if err != nil { + t.Fatalf("url.Parse() error: %v", err) + } + if gotURL.String() != wantURL.String() { + t.Fatalf("WS proxy = %q, want %q", gotURL.String(), wantURL.String()) + } +} + +func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + + if err := applyDiscordProxy(session, "://bad-proxy"); err == nil { + t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ca5803c35..e8b2a65e7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -230,6 +230,7 @@ type FeishuConfig struct { type DiscordConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` } diff --git a/pkg/utils/media.go b/pkg/utils/media.go index a34889fb8..3e1c5d88e 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -3,6 +3,7 @@ package utils import ( "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -52,11 +53,12 @@ type DownloadOptions struct { Timeout time.Duration ExtraHeaders map[string]string LoggerPrefix string + ProxyURL string } // DownloadFile downloads a file from URL to a local temp directory. // Returns the local file path or empty string on error. -func DownloadFile(url, filename string, opts DownloadOptions) string { +func DownloadFile(urlStr, filename string, opts DownloadOptions) string { // Set defaults if opts.Timeout == 0 { opts.Timeout = 60 * time.Second @@ -78,7 +80,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName) // Create HTTP request - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequest("GET", urlStr, nil) if err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{ "error": err.Error(), @@ -92,11 +94,24 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { } client := &http.Client{Timeout: opts.Timeout} + if opts.ProxyURL != "" { + proxyURL, parseErr := url.Parse(opts.ProxyURL) + if parseErr != nil { + logger.ErrorCF(opts.LoggerPrefix, "Invalid proxy URL for download", map[string]any{ + "error": parseErr.Error(), + "proxy": opts.ProxyURL, + }) + return "" + } + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } resp, err := client.Do(req) if err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{ "error": err.Error(), - "url": url, + "url": urlStr, }) return "" } @@ -105,7 +120,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { if resp.StatusCode != http.StatusOK { logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]any{ "status": resp.StatusCode, - "url": url, + "url": urlStr, }) return "" } From b0c8fc4a7ed21657a56d487ac8636e6cdd2678eb Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Sat, 28 Feb 2026 23:32:15 +0200 Subject: [PATCH 058/132] 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 059/132] 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 060/132] 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 061/132] 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 062/132] 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 6d894d6138cb89a8bc714d69b03c9a6a14cb03d7 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Sun, 1 Mar 2026 14:46:54 +0800 Subject: [PATCH 063/132] refactor(memory): use fileutil.WriteFileAtomic and log corrupt lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace manual temp+rename in writeMeta and rewriteJSONL with the project's standard fileutil.WriteFileAtomic. This adds fsync before rename, which is important for flash storage on embedded devices where power loss can leave zero-length files after an unsynced rename. - Log a warning when readMessages skips a corrupt line, so operators can see that data was lost after a crash instead of silently dropping it. - Document the lossy sanitizeKey mapping (telegram:123 → telegram_123) as an intentional tradeoff. --- pkg/memory/jsonl.go | 79 ++++++++++++++++----------------------------- 1 file changed, 27 insertions(+), 52 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 222d91f02..efd4347c0 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -2,16 +2,19 @@ package memory import ( "bufio" + "bytes" "context" "encoding/json" "fmt" "hash/fnv" + "log" "os" "path/filepath" "strings" "sync" "time" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -83,6 +86,12 @@ func (s *JSONLStore) metaPath(key string) string { // sanitizeKey converts a session key to a safe filename component. // Mirrors pkg/session.sanitizeFilename so that migration paths match. +// +// Note: this is a lossy mapping — "telegram:123" and "telegram_123" +// both produce the same filename. This is an intentional tradeoff: +// keys with colons (e.g. from channels) are by far the common case, +// and a bidirectional encoding (like URL-encoding) would complicate +// file listings and debugging. func sanitizeKey(key string) string { return strings.ReplaceAll(key, ":", "_") } @@ -105,27 +114,14 @@ func (s *JSONLStore) readMeta(key string) (sessionMeta, error) { return meta, nil } -// writeMeta atomically writes the metadata file (temp + rename). +// writeMeta atomically writes the metadata file using the project's +// standard WriteFileAtomic (temp + fsync + rename). func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { data, err := json.MarshalIndent(meta, "", " ") if err != nil { return fmt.Errorf("memory: encode meta: %w", err) } - - target := s.metaPath(key) - tmp := target + ".tmp" - - err = os.WriteFile(tmp, data, 0o644) - if err != nil { - return fmt.Errorf("memory: write meta tmp: %w", err) - } - - err = os.Rename(tmp, target) - if err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("memory: rename meta: %w", err) - } - return nil + return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644) } // readMessages reads valid JSON lines from a .jsonl file, skipping @@ -158,9 +154,13 @@ func readMessages(path string, skip int) ([]providers.Message, error) { continue } var msg providers.Message - if json.Unmarshal(line, &msg) != nil { + if err := json.Unmarshal(line, &msg); err != nil { // Corrupt line — likely a partial write from a crash. - // Skip it; this is the standard JSONL recovery pattern. + // Log so operators know data was skipped, but don't + // fail the entire read; this is the standard JSONL + // recovery pattern. + log.Printf("memory: skipping corrupt line %d in %s: %v", + lineNum, filepath.Base(path), err) continue } msgs = append(msgs, msg) @@ -430,46 +430,21 @@ func (s *JSONLStore) Compact( return s.rewriteJSONL(sessionKey, active) } -// rewriteJSONL atomically replaces the JSONL file with the given messages. +// rewriteJSONL atomically replaces the JSONL file with the given messages +// using the project's standard WriteFileAtomic (temp + fsync + rename). func (s *JSONLStore) rewriteJSONL( sessionKey string, msgs []providers.Message, ) error { - target := s.jsonlPath(sessionKey) - tmp := target + ".tmp" - - f, err := os.Create(tmp) - if err != nil { - return fmt.Errorf("memory: create jsonl tmp: %w", err) - } - + var buf bytes.Buffer for i, msg := range msgs { - line, marshalErr := json.Marshal(msg) - if marshalErr != nil { - f.Close() - _ = os.Remove(tmp) - return fmt.Errorf("memory: marshal message %d: %w", i, marshalErr) - } - line = append(line, '\n') - _, writeErr := f.Write(line) - if writeErr != nil { - f.Close() - _ = os.Remove(tmp) - return fmt.Errorf("memory: write message %d: %w", i, writeErr) + line, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("memory: marshal message %d: %w", i, err) } + buf.Write(line) + buf.WriteByte('\n') } - - err = f.Close() - if err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("memory: close jsonl tmp: %w", err) - } - - err = os.Rename(tmp, target) - if err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("memory: rename jsonl: %w", err) - } - return nil + return fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), 0o644) } func (s *JSONLStore) Close() error { From 32c864c309a7717b0254f270444df11ade1868f8 Mon Sep 17 00:00:00 2001 From: Kai Xia Date: Sun, 1 Mar 2026 18:17:32 +1100 Subject: [PATCH 064/132] enable dupl check Signed-off-by: Kai Xia --- .golangci.yaml | 1 - .../internal/ui/channel.go | 258 ++++-------------- pkg/agent/instance_test.go | 123 ++++----- pkg/agent/loop_test.go | 82 ++---- pkg/channels/manager.go | 102 +++---- pkg/channels/wecom/app.go | 76 ++---- pkg/channels/wecom/app_test.go | 54 ---- pkg/channels/wecom/bot_test.go | 63 ++--- pkg/config/config.go | 20 +- pkg/heartbeat/service_test.go | 118 ++++---- pkg/migrate/internal/common_test.go | 93 +++---- pkg/providers/claude_cli_provider.go | 29 +- pkg/providers/codex_cli_provider.go | 29 +- pkg/providers/toolcall_utils.go | 38 ++- 14 files changed, 347 insertions(+), 739 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index d0ba90716..ea3107ec8 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -7,7 +7,6 @@ linters: - containedctx - cyclop - depguard - - dupl - dupword - err113 - exhaustruct diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go index ad9171424..49a6ccc5d 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go @@ -10,8 +10,8 @@ import ( picoclawconfig "github.com/sipeed/picoclaw/pkg/config" ) -func (s *appState) channelMenu() tview.Primitive { - items := []MenuItem{ +func (s *appState) buildChannelMenuItems() []MenuItem { + return []MenuItem{ {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, channelItem( "Telegram", @@ -86,8 +86,10 @@ func (s *appState) channelMenu() tview.Primitive { func() { s.push("channel-wecomapp", s.wecomAppForm()) }, ), } +} - menu := NewMenu("Channels", items) +func (s *appState) channelMenu() tview.Primitive { + menu := NewMenu("Channels", s.buildChannelMenuItems()) menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc { s.pop() @@ -103,199 +105,72 @@ func (s *appState) channelMenu() tview.Primitive { } func refreshChannelMenuFromState(menu *Menu, s *appState) { - items := []MenuItem{ - {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, - channelItem( - "Telegram", - "Telegram bot settings", - s.config.Channels.Telegram.Enabled, - func() { s.push("channel-telegram", s.telegramForm()) }, - ), - channelItem( - "Discord", - "Discord bot settings", - s.config.Channels.Discord.Enabled, - func() { s.push("channel-discord", s.discordForm()) }, - ), - channelItem( - "QQ", - "QQ bot settings", - s.config.Channels.QQ.Enabled, - func() { s.push("channel-qq", s.qqForm()) }, - ), - channelItem( - "MaixCam", - "MaixCam gateway", - s.config.Channels.MaixCam.Enabled, - func() { s.push("channel-maixcam", s.maixcamForm()) }, - ), - channelItem( - "WhatsApp", - "WhatsApp bridge", - s.config.Channels.WhatsApp.Enabled, - func() { s.push("channel-whatsapp", s.whatsappForm()) }, - ), - channelItem( - "Feishu", - "Feishu bot settings", - s.config.Channels.Feishu.Enabled, - func() { s.push("channel-feishu", s.feishuForm()) }, - ), - channelItem( - "DingTalk", - "DingTalk bot settings", - s.config.Channels.DingTalk.Enabled, - func() { s.push("channel-dingtalk", s.dingtalkForm()) }, - ), - channelItem( - "Slack", - "Slack bot settings", - s.config.Channels.Slack.Enabled, - func() { s.push("channel-slack", s.slackForm()) }, - ), - channelItem( - "LINE", - "LINE bot settings", - s.config.Channels.LINE.Enabled, - func() { s.push("channel-line", s.lineForm()) }, - ), - channelItem( - "OneBot", - "OneBot settings", - s.config.Channels.OneBot.Enabled, - func() { s.push("channel-onebot", s.onebotForm()) }, - ), - channelItem( - "WeCom", - "WeCom bot settings", - s.config.Channels.WeCom.Enabled, - func() { s.push("channel-wecom", s.wecomForm()) }, - ), - channelItem( - "WeCom App", - "WeCom App settings", - s.config.Channels.WeComApp.Enabled, - func() { s.push("channel-wecomapp", s.wecomAppForm()) }, - ), - } - menu.applyItems(items) + menu.applyItems(s.buildChannelMenuItems()) } func (s *appState) telegramForm() tview.Primitive { cfg := &s.config.Channels.Telegram - form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { cfg.Token = strings.TrimSpace(text) }) form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) { cfg.Proxy = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) discordForm() tview.Primitive { cfg := &s.config.Channels.Discord - form := baseChannelForm("Discord", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { cfg.Token = strings.TrimSpace(text) }) form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) { cfg.MentionOnly = checked }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) qqForm() tview.Primitive { cfg := &s.config.Channels.QQ - form := baseChannelForm("QQ", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { cfg.AppID = strings.TrimSpace(text) }) form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { cfg.AppSecret = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) maixcamForm() tview.Primitive { cfg := &s.config.Channels.MaixCam - form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Host", cfg.Host, 64, nil, func(text string) { cfg.Host = strings.TrimSpace(text) }) addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) whatsappForm() tview.Primitive { cfg := &s.config.Channels.WhatsApp - form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) { cfg.BridgeURL = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) feishuForm() tview.Primitive { cfg := &s.config.Channels.Feishu - form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { cfg.AppID = strings.TrimSpace(text) }) @@ -308,66 +183,39 @@ func (s *appState) feishuForm() tview.Primitive { form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) { cfg.VerificationToken = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) dingtalkForm() tview.Primitive { cfg := &s.config.Channels.DingTalk - form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) { cfg.ClientID = strings.TrimSpace(text) }) form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) { cfg.ClientSecret = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) slackForm() tview.Primitive { cfg := &s.config.Channels.Slack - form := baseChannelForm("Slack", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) { cfg.BotToken = strings.TrimSpace(text) }) form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) { cfg.AppToken = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) lineForm() tview.Primitive { cfg := &s.config.Channels.LINE - form := baseChannelForm("LINE", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) { cfg.ChannelSecret = strings.TrimSpace(text) }) @@ -381,22 +229,13 @@ func (s *appState) lineForm() tview.Primitive { form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { cfg.WebhookPath = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) onebotForm() tview.Primitive { cfg := &s.config.Channels.OneBot - form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) { cfg.WSUrl = strings.TrimSpace(text) }) @@ -418,22 +257,13 @@ func (s *appState) onebotForm() tview.Primitive { cfg.GroupTriggerPrefix = splitCSV(text) }, ) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) wecomForm() tview.Primitive { cfg := &s.config.Channels.WeCom - form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { cfg.Token = strings.TrimSpace(text) }) @@ -450,9 +280,7 @@ func (s *appState) wecomForm() tview.Primitive { form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { cfg.WebhookPath = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) addIntField( form, "Reply Timeout", @@ -464,14 +292,7 @@ func (s *appState) wecomForm() tview.Primitive { func (s *appState) wecomAppForm() tview.Primitive { cfg := &s.config.Channels.WeComApp - form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) { - cfg.Enabled = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - }) + form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) { cfg.CorpID = strings.TrimSpace(text) }) @@ -492,9 +313,7 @@ func (s *appState) wecomAppForm() tview.Primitive { form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { cfg.WebhookPath = strings.TrimSpace(text) }) - form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { - cfg.AllowFrom = splitCSV(text) - }) + addAllowFromField(form, &cfg.AllowFrom) addIntField( form, "Reply Timeout", @@ -504,6 +323,23 @@ func (s *appState) wecomAppForm() tview.Primitive { return wrapWithBack(form, s) } +func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) { + return func(v bool) { + *enabledPtr = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + } +} + +func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) { + form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) { + *allowFrom = splitCSV(text) + }) +} + func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form { form := tview.NewForm() form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title)) diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index af1bf2ead..4f41ecd1c 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -95,75 +95,68 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { } func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "step-3.5-flash", - }, + tests := []struct { + name string + aliasName string + modelName string + apiBase string + wantProvider string + wantModel string + }{ + { + name: "alias with provider prefix", + aliasName: "step-3.5-flash", + modelName: "openrouter/stepfun/step-3.5-flash:free", + apiBase: "https://openrouter.ai/api/v1", + wantProvider: "openrouter", + wantModel: "stepfun/step-3.5-flash:free", }, - ModelList: []config.ModelConfig{ - { - ModelName: "step-3.5-flash", - Model: "openrouter/stepfun/step-3.5-flash:free", - APIBase: "https://openrouter.ai/api/v1", - }, + { + name: "alias without provider prefix", + aliasName: "glm-5", + modelName: "glm-5", + apiBase: "https://api.z.ai/api/coding/paas/v4", + wantProvider: "openai", + wantModel: "glm-5", }, } - provider := &mockProvider{} - agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) - if len(agent.Candidates) != 1 { - t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) - } - if agent.Candidates[0].Provider != "openrouter" { - t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter") - } - if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" { - t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free") - } -} - -func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "glm-5", - }, - }, - ModelList: []config.ModelConfig{ - { - ModelName: "glm-5", - Model: "glm-5", - APIBase: "https://api.z.ai/api/coding/paas/v4", - }, - }, - } - - provider := &mockProvider{} - agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) - - if len(agent.Candidates) != 1 { - t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) - } - if agent.Candidates[0].Provider != "openai" { - t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai") - } - if agent.Candidates[0].Model != "glm-5" { - t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5") + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: tt.aliasName, + }, + }, + ModelList: []config.ModelConfig{ + { + ModelName: tt.aliasName, + Model: tt.modelName, + APIBase: tt.apiBase, + }, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if len(agent.Candidates) != 1 { + t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) + } + if agent.Candidates[0].Provider != tt.wantProvider { + t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider) + } + if agent.Candidates[0].Model != tt.wantModel { + t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel) + } + }) } } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 801b6a46e..51cca90cf 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -26,16 +26,15 @@ func (f *fakeChannel) IsAllowed(string) bool { func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } func (f *fakeChannel) ReasoningChannelID() string { return f.id } -func TestRecordLastChannel(t *testing.T) { - // Create temp workspace +func newTestAgentLoop( + t *testing.T, +) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { + t.Helper() tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) - - // Create test config - cfg := &config.Config{ + cfg = &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, @@ -45,74 +44,43 @@ func TestRecordLastChannel(t *testing.T) { }, }, } + msgBus = bus.NewMessageBus() + provider = &mockProvider{} + al = NewAgentLoop(cfg, msgBus, provider) + return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } +} - // Create agent loop - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) +func TestRecordLastChannel(t *testing.T) { + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() - // Test RecordLastChannel testChannel := "test-channel" - err = al.RecordLastChannel(testChannel) - if err != nil { + if err := al.RecordLastChannel(testChannel); err != nil { t.Fatalf("RecordLastChannel failed: %v", err) } - - // Verify channel was saved - lastChannel := al.state.GetLastChannel() - if lastChannel != testChannel { - t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel) + if got := al.state.GetLastChannel(); got != testChannel { + t.Errorf("Expected channel '%s', got '%s'", testChannel, got) } - - // Verify persistence by creating a new agent loop al2 := NewAgentLoop(cfg, msgBus, provider) - if al2.state.GetLastChannel() != testChannel { - t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel()) + if got := al2.state.GetLastChannel(); got != testChannel { + t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got) } } func TestRecordLastChatID(t *testing.T) { - // Create temp workspace - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() - // Create test config - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - // Create agent loop - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) - - // Test RecordLastChatID testChatID := "test-chat-id-123" - err = al.RecordLastChatID(testChatID) - if err != nil { + if err := al.RecordLastChatID(testChatID); err != nil { t.Fatalf("RecordLastChatID failed: %v", err) } - - // Verify chat ID was saved - lastChatID := al.state.GetLastChatID() - if lastChatID != testChatID { - t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID) + if got := al.state.GetLastChatID(); got != testChatID { + t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got) } - - // Verify persistence by creating a new agent loop al2 := NewAgentLoop(cfg, msgBus, provider) - if al2.state.GetLastChatID() != testChatID { - t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID()) + if got := al2.state.GetLastChatID(); got != testChatID { + t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got) } } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 155e50b39..be48f85fc 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -539,86 +539,88 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork }) } -func (m *Manager) dispatchOutbound(ctx context.Context) { - logger.InfoC("channels", "Outbound dispatcher started") +func dispatchLoop[M any]( + ctx context.Context, + m *Manager, + subscribe func(context.Context) (M, bool), + getChannel func(M) string, + enqueue func(context.Context, *channelWorker, M) bool, + startMsg, stopMsg, unknownMsg, noWorkerMsg string, +) { + logger.InfoC("channels", startMsg) for { - msg, ok := m.bus.SubscribeOutbound(ctx) + msg, ok := subscribe(ctx) if !ok { - logger.InfoC("channels", "Outbound dispatcher stopped") + logger.InfoC("channels", stopMsg) return } + channel := getChannel(msg) + // Silently skip internal channels - if constants.IsInternalChannel(msg.Channel) { + if constants.IsInternalChannel(channel) { continue } m.mu.RLock() - _, exists := m.channels[msg.Channel] - w, wExists := m.workers[msg.Channel] + _, exists := m.channels[channel] + w, wExists := m.workers[channel] m.mu.RUnlock() if !exists { - logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ - "channel": msg.Channel, - }) + logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel}) continue } if wExists && w != nil { - select { - case w.queue <- msg: - case <-ctx.Done(): + if !enqueue(ctx, w, msg) { return } } else if exists { - logger.WarnCF("channels", "Channel has no active worker, skipping message", map[string]any{ - "channel": msg.Channel, - }) + logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel}) } } } +func (m *Manager) dispatchOutbound(ctx context.Context) { + dispatchLoop( + ctx, m, + m.bus.SubscribeOutbound, + func(msg bus.OutboundMessage) string { return msg.Channel }, + func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { + select { + case w.queue <- msg: + return true + case <-ctx.Done(): + return false + } + }, + "Outbound dispatcher started", + "Outbound dispatcher stopped", + "Unknown channel for outbound message", + "Channel has no active worker, skipping message", + ) +} + func (m *Manager) dispatchOutboundMedia(ctx context.Context) { - logger.InfoC("channels", "Outbound media dispatcher started") - - for { - msg, ok := m.bus.SubscribeOutboundMedia(ctx) - if !ok { - logger.InfoC("channels", "Outbound media dispatcher stopped") - return - } - - // Silently skip internal channels - if constants.IsInternalChannel(msg.Channel) { - continue - } - - m.mu.RLock() - _, exists := m.channels[msg.Channel] - w, wExists := m.workers[msg.Channel] - m.mu.RUnlock() - - if !exists { - logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{ - "channel": msg.Channel, - }) - continue - } - - if wExists && w != nil { + dispatchLoop( + ctx, m, + m.bus.SubscribeOutboundMedia, + func(msg bus.OutboundMediaMessage) string { return msg.Channel }, + func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { select { case w.mediaQueue <- msg: + return true case <-ctx.Done(): - return + return false } - } else if exists { - logger.WarnCF("channels", "Channel has no active worker, skipping media message", map[string]any{ - "channel": msg.Channel, - }) - } - } + }, + "Outbound media dispatcher started", + "Outbound media dispatcher stopped", + "Unknown channel for outbound media message", + "Channel has no active worker, skipping media message", + ) } // runMediaWorker processes outbound media messages for a single channel. diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 292a71fd2..b79340315 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -342,18 +342,11 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp return result.MediaID, nil } -// sendImageMessage sends an image message using a media_id. -func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { +// sendWeComMessage marshals payload and POSTs it to the WeCom message API. +func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error { apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) - msg := WeComImageMessage{ - ToUser: userID, - MsgType: "image", - AgentID: c.config.AgentID, - } - msg.Image.MediaID = mediaID - - jsonData, err := json.Marshal(msg) + jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal message: %w", err) } @@ -400,6 +393,17 @@ func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, use return nil } +// sendImageMessage sends an image message using a media_id. +func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { + msg := WeComImageMessage{ + ToUser: userID, + MsgType: "image", + AgentID: c.config.AgentID, + } + msg.Image.MediaID = mediaID + return c.sendWeComMessage(ctx, accessToken, msg) +} + // WebhookPath returns the path for registering on the shared HTTP server. func (c *WeComAppChannel) WebhookPath() string { if c.config.WebhookPath != "" { @@ -722,63 +726,15 @@ func (c *WeComAppChannel) getAccessToken() string { return c.accessToken } -// sendTextMessage sends a text message to a user +// sendTextMessage sends a text message to a user. func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error { - apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) - msg := WeComTextMessage{ ToUser: userID, MsgType: "text", AgentID: c.config.AgentID, } msg.Text.Content = content - - jsonData, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - // Use configurable timeout (default 5 seconds) - timeout := c.config.ReplyTimeout - if timeout <= 0 { - timeout = 5 - } - - reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.client.Do(req) - if err != nil { - return channels.ClassifyNetError(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body))) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var sendResp WeComSendMessageResponse - if err := json.Unmarshal(body, &sendResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if sendResp.ErrCode != 0 { - return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) - } - - return nil + return c.sendWeComMessage(ctx, accessToken, msg) } // handleHealth handles health check requests diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go index 5420949de..8a5faaaa8 100644 --- a/pkg/channels/wecom/app_test.go +++ b/pkg/channels/wecom/app_test.go @@ -323,60 +323,6 @@ func TestWeComAppDecryptMessage(t *testing.T) { }) } -func TestWeComAppPKCS7Unpad(t *testing.T) { - tests := []struct { - name string - input []byte - expected []byte - }{ - { - name: "empty input", - input: []byte{}, - expected: []byte{}, - }, - { - name: "valid padding 3 bytes", - input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...), - expected: []byte("hello"), - }, - { - name: "valid padding 16 bytes (full block)", - input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...), - expected: []byte("123456789012345"), - }, - { - name: "invalid padding larger than data", - input: []byte{20}, - expected: nil, // should return error - }, - { - name: "invalid padding zero", - input: append([]byte("test"), byte(0)), - expected: nil, // should return error - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := pkcs7Unpad(tt.input) - if tt.expected == nil { - // This case should return an error - if err == nil { - t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result) - } - return - } - if err != nil { - t.Errorf("pkcs7Unpad() unexpected error: %v", err) - return - } - if !bytes.Equal(result, tt.expected) { - t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected) - } - }) - } -} - func TestWeComAppHandleVerification(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKeyApp() diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go index 328b145c2..1950800c9 100644 --- a/pkg/channels/wecom/bot_test.go +++ b/pkg/channels/wecom/bot_test.go @@ -412,22 +412,9 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { } ch, _ := NewWeComBotChannel(cfg, msgBus) - t.Run("valid direct message callback", func(t *testing.T) { - // Create JSON message for direct chat (single) - jsonMsg := `{ - "msgid": "test_msg_id_123", - "aibotid": "test_aibot_id", - "chattype": "single", - "from": {"userid": "user123"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello World"} - }` - - // Encrypt message + runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder { + t.Helper() encrypted, _ := encryptTestMessage(jsonMsg, aesKey) - - // Create encrypted XML wrapper encryptedWrapper := struct { XMLName xml.Name `xml:"xml"` Encrypt string `xml:"Encrypt"` @@ -435,20 +422,29 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { Encrypt: encrypted, } wrapperData, _ := xml.Marshal(encryptedWrapper) - timestamp := "1234567890" nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest( http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData), ) w := httptest.NewRecorder() - ch.handleMessageCallback(context.Background(), w, req) + return w + } + t.Run("valid direct message callback", func(t *testing.T) { + w := runBotMessageCallback(t, `{ + "msgid": "test_msg_id_123", + "aibotid": "test_aibot_id", + "chattype": "single", + "from": {"userid": "user123"}, + "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + "msgtype": "text", + "text": {"content": "Hello World"} + }`) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } @@ -458,8 +454,7 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { }) t.Run("valid group message callback", func(t *testing.T) { - // Create JSON message for group chat - jsonMsg := `{ + w := runBotMessageCallback(t, `{ "msgid": "test_msg_id_456", "aibotid": "test_aibot_id", "chatid": "group_chat_id_123", @@ -468,33 +463,7 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", "msgtype": "text", "text": {"content": "Hello Group"} - }` - - // Encrypt message - encrypted, _ := encryptTestMessage(jsonMsg, aesKey) - - // Create encrypted XML wrapper - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: encrypted, - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encrypted) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - + }`) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } diff --git a/pkg/config/config.go b/pkg/config/config.go index d84772d2b..4210bf309 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -742,25 +742,7 @@ func (c *Config) findMatches(modelName string) []ModelConfig { // HasProvidersConfig checks if any provider in the old providers config has configuration. func (c *Config) HasProvidersConfig() bool { - v := c.Providers - return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" || - v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" || - v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" || - v.Groq.APIKey != "" || v.Groq.APIBase != "" || - v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" || - v.VLLM.APIKey != "" || v.VLLM.APIBase != "" || - v.Gemini.APIKey != "" || v.Gemini.APIBase != "" || - v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" || - v.Ollama.APIKey != "" || v.Ollama.APIBase != "" || - v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" || - v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" || - v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" || - v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" || - v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" || - v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" || - v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" || - v.Qwen.APIKey != "" || v.Qwen.APIBase != "" || - v.Mistral.APIKey != "" || v.Mistral.APIBase != "" + return !c.Providers.IsEmpty() } // ValidateModelList validates all ModelConfig entries in the model_list. diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go index a7aef8c3a..3b7eeeefb 100644 --- a/pkg/heartbeat/service_test.go +++ b/pkg/heartbeat/service_test.go @@ -47,79 +47,63 @@ func TestExecuteHeartbeat_Async(t *testing.T) { } } -func TestExecuteHeartbeat_Error(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - hs := NewHeartbeatService(tmpDir, 30, true) - hs.stopChan = make(chan struct{}) // Enable for testing - - hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - return &tools.ToolResult{ - ForLLM: "Heartbeat failed: connection error", - ForUser: "", - Silent: false, - IsError: true, - Async: false, - } - }) - - // Create HEARTBEAT.md - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) - - hs.executeHeartbeat() - - // Check log file for error message - logFile := filepath.Join(tmpDir, "heartbeat.log") - data, err := os.ReadFile(logFile) - if err != nil { - t.Fatalf("Failed to read log file: %v", err) +func TestExecuteHeartbeat_ResultLogging(t *testing.T) { + tests := []struct { + name string + result *tools.ToolResult + wantLog string + }{ + { + name: "error result", + result: &tools.ToolResult{ + ForLLM: "Heartbeat failed: connection error", + ForUser: "", + Silent: false, + IsError: true, + Async: false, + }, + wantLog: "error message", + }, + { + name: "silent result", + result: &tools.ToolResult{ + ForLLM: "Heartbeat completed successfully", + ForUser: "", + Silent: true, + IsError: false, + Async: false, + }, + wantLog: "completion message", + }, } - logContent := string(data) - if logContent == "" { - t.Error("Expected log file to contain error message") - } -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) -func TestExecuteHeartbeat_Silent(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) // Enable for testing - hs := NewHeartbeatService(tmpDir, 30, true) - hs.stopChan = make(chan struct{}) // Enable for testing + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + return tt.result + }) - hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - return &tools.ToolResult{ - ForLLM: "Heartbeat completed successfully", - ForUser: "", - Silent: true, - IsError: false, - Async: false, - } - }) + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) + hs.executeHeartbeat() - // Create HEARTBEAT.md - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) - - hs.executeHeartbeat() - - // Check log file for completion message - logFile := filepath.Join(tmpDir, "heartbeat.log") - data, err := os.ReadFile(logFile) - if err != nil { - t.Fatalf("Failed to read log file: %v", err) - } - - logContent := string(data) - if logContent == "" { - t.Error("Expected log file to contain completion message") + logFile := filepath.Join(tmpDir, "heartbeat.log") + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + if string(data) == "" { + t.Errorf("Expected log file to contain %s", tt.wantLog) + } + }) } } diff --git a/pkg/migrate/internal/common_test.go b/pkg/migrate/internal/common_test.go index a089157f5..a67293c19 100644 --- a/pkg/migrate/internal/common_test.go +++ b/pkg/migrate/internal/common_test.go @@ -118,64 +118,55 @@ func TestPlanWorkspaceMigration(t *testing.T) { assert.GreaterOrEqual(t, len(actions), 1) } -func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) { - tmpDir := t.TempDir() - srcWorkspace := filepath.Join(tmpDir, "src", "workspace") - dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") +func TestPlanWorkspaceMigrationExistingFile(t *testing.T) { + tests := []struct { + name string + force bool + wantActionType ActionType + }{ + { + name: "backup when not forced", + force: false, + wantActionType: ActionBackup, + }, + { + name: "copy when forced", + force: true, + wantActionType: ActionCopy, + }, + } - err := os.MkdirAll(srcWorkspace, 0o755) - require.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + srcWorkspace := filepath.Join(tmpDir, "src", "workspace") + dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") - err = os.MkdirAll(dstWorkspace, 0o755) - require.NoError(t, err) + err := os.MkdirAll(srcWorkspace, 0o755) + require.NoError(t, err) - err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644) - require.NoError(t, err) + err = os.MkdirAll(dstWorkspace, 0o755) + require.NoError(t, err) - err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644) - require.NoError(t, err) + err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644) + require.NoError(t, err) - actions, err := PlanWorkspaceMigration( - srcWorkspace, - dstWorkspace, - []string{"file1.txt"}, - []string{}, - false, - ) - require.NoError(t, err) + err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644) + require.NoError(t, err) - require.GreaterOrEqual(t, len(actions), 1) - assert.Equal(t, ActionBackup, actions[0].Type) -} + actions, err := PlanWorkspaceMigration( + srcWorkspace, + dstWorkspace, + []string{"file1.txt"}, + []string{}, + tt.force, + ) + require.NoError(t, err) -func TestPlanWorkspaceMigrationForce(t *testing.T) { - tmpDir := t.TempDir() - srcWorkspace := filepath.Join(tmpDir, "src", "workspace") - dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") - - err := os.MkdirAll(srcWorkspace, 0o755) - require.NoError(t, err) - - err = os.MkdirAll(dstWorkspace, 0o755) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644) - require.NoError(t, err) - - actions, err := PlanWorkspaceMigration( - srcWorkspace, - dstWorkspace, - []string{"file1.txt"}, - []string{}, - true, - ) - require.NoError(t, err) - - require.GreaterOrEqual(t, len(actions), 1) - assert.Equal(t, ActionCopy, actions[0].Type) + require.GreaterOrEqual(t, len(actions), 1) + assert.Equal(t, tt.wantActionType, actions[0].Type) + }) + } } func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) { diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 74ec33b98..5c995b219 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -108,34 +108,7 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe // buildToolsPrompt creates the tool definitions section for the system prompt. func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString( - `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, - ) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - paramsJSON, _ := json.Marshal(tool.Function.Parameters) - sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) - } - sb.WriteString("\n") - } - - return sb.String() + return buildCLIToolsPrompt(tools) } // parseClaudeCliResponse parses the JSON output from the claude CLI. diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 4c783ece5..3cb3343eb 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -130,34 +130,7 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio // buildToolsPrompt creates a tool definitions section for the prompt. func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString( - `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, - ) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - paramsJSON, _ := json.Marshal(tool.Function.Parameters) - sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) - } - sb.WriteString("\n") - } - - return sb.String() + return buildCLIToolsPrompt(tools) } // codexEvent represents a single JSONL event from `codex exec --json`. diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go index 49218b1b1..a33e1eb5c 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/toolcall_utils.go @@ -5,7 +5,43 @@ package providers -import "encoding/json" +import ( + "encoding/json" + "fmt" + "strings" +) + +// buildCLIToolsPrompt creates the tool definitions section for a CLI provider system prompt. +func buildCLIToolsPrompt(tools []ToolDefinition) string { + var sb strings.Builder + + sb.WriteString("## Available Tools\n\n") + sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") + sb.WriteString("```json\n") + sb.WriteString( + `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, + ) + sb.WriteString("\n```\n\n") + sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") + sb.WriteString("### Tool Definitions:\n\n") + + for _, tool := range tools { + if tool.Type != "function" { + continue + } + sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) + if tool.Function.Description != "" { + sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) + } + if len(tool.Function.Parameters) > 0 { + paramsJSON, _ := json.Marshal(tool.Function.Parameters) + sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) + } + sb.WriteString("\n") + } + + return sb.String() +} // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // It handles cases where Name/Arguments might be in different locations (top-level vs Function) From 434b03ed677578dfe2270262e8cfa0ebe77cda24 Mon Sep 17 00:00:00 2001 From: Kai Xia Date: Sun, 1 Mar 2026 18:24:11 +1100 Subject: [PATCH 065/132] remove wrapper methods Signed-off-by: Kai Xia --- pkg/providers/claude_cli_provider.go | 7 +------ pkg/providers/claude_cli_provider_test.go | 9 +++------ pkg/providers/codex_cli_provider.go | 7 +------ 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 5c995b219..6c4f6a767 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -100,17 +100,12 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe } if len(tools) > 0 { - parts = append(parts, p.buildToolsPrompt(tools)) + parts = append(parts, buildCLIToolsPrompt(tools)) } return strings.Join(parts, "\n\n") } -// buildToolsPrompt creates the tool definitions section for the system prompt. -func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - return buildCLIToolsPrompt(tools) -} - // parseClaudeCliResponse parses the JSON output from the claude CLI. func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { var resp claudeCliJSONResponse diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 3a3cafaca..d4d648f5a 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -660,12 +660,11 @@ func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) { // --- buildToolsPrompt tests --- func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { - p := NewClaudeCliProvider("/workspace") tools := []ToolDefinition{ {Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}}, {Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}}, } - got := p.buildToolsPrompt(tools) + got := buildCLIToolsPrompt(tools) if strings.Contains(got, "skip_me") { t.Error("buildToolsPrompt() should skip non-function tools") } @@ -675,11 +674,10 @@ func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { } func TestBuildToolsPrompt_NoDescription(t *testing.T) { - p := NewClaudeCliProvider("/workspace") tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}}, } - got := p.buildToolsPrompt(tools) + got := buildCLIToolsPrompt(tools) if !strings.Contains(got, "bare_tool") { t.Error("should include tool name") } @@ -689,14 +687,13 @@ func TestBuildToolsPrompt_NoDescription(t *testing.T) { } func TestBuildToolsPrompt_NoParameters(t *testing.T) { - p := NewClaudeCliProvider("/workspace") tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{ Name: "no_params_tool", Description: "A tool with no parameters", }}, } - got := p.buildToolsPrompt(tools) + got := buildCLIToolsPrompt(tools) if strings.Contains(got, "Parameters:") { t.Error("should not include Parameters: section when nil") } diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 3cb3343eb..13f53ad9e 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -115,7 +115,7 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio } if len(tools) > 0 { - sb.WriteString(p.buildToolsPrompt(tools)) + sb.WriteString(buildCLIToolsPrompt(tools)) sb.WriteString("\n\n") } @@ -128,11 +128,6 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio return sb.String() } -// buildToolsPrompt creates a tool definitions section for the prompt. -func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - return buildCLIToolsPrompt(tools) -} - // codexEvent represents a single JSONL event from `codex exec --json`. type codexEvent struct { Type string `json:"type"` From 9efdde25ad7fc63ccb869c4601c469491b6d31c7 Mon Sep 17 00:00:00 2001 From: winterfx Date: Sun, 1 Mar 2026 16:23:05 +0800 Subject: [PATCH 066/132] fix: preserve reasoning_content in multi-turn conversation history The openaiMessage struct and stripSystemParts() were not carrying over the ReasoningContent field when serializing conversation history for API requests. This caused thinking models (e.g. kimi-k2.5) to receive incomplete assistant messages on subsequent turns, resulting in 400 errors from the Moonshot API. Add the ReasoningContent field to openaiMessage and copy it in stripSystemParts(). Also add a test to verify reasoning_content is preserved when sending conversation history. Fixes #588 Related: #876 Co-Authored-By: Claude Opus 4.6 --- pkg/providers/openai_compat/provider.go | 18 +++---- pkg/providers/openai_compat/provider_test.go | 50 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index d922ed5f7..74e612046 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -289,10 +289,11 @@ func parseResponse(body []byte) (*LLMResponse, error) { // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` } // stripSystemParts converts []Message to []openaiMessage, dropping the @@ -302,10 +303,11 @@ func stripSystemParts(messages []Message) []openaiMessage { out := make([]openaiMessage, len(messages)) for i, m := range messages { out[i] = openaiMessage{ - Role: m.Role, - Content: m.Content, - ToolCalls: m.ToolCalls, - ToolCallID: m.ToolCallID, + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, } } return out diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 7247fea3e..d9e6ba871 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -146,6 +146,56 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) { } } +func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + + // Simulate a multi-turn conversation where the assistant's previous + // reply included reasoning_content (e.g. from kimi-k2.5). + messages := []Message{ + {Role: "user", Content: "What is 1+1?"}, + {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, + {Role: "user", Content: "What about 2+2?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "kimi-k2.5", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_content is preserved in the serialized request. + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + assistantMsg, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) + } + if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" { + t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"]) + } +} + func TestProviderChat_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) From b1386ad71fbe43142b96deca018d73294df06fcc Mon Sep 17 00:00:00 2001 From: Dimitrij Denissenko Date: Sun, 1 Mar 2026 08:31:04 +0000 Subject: [PATCH 067/132] Fix voice transcription --- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-br.md | 2 +- README.vi.md | 2 +- README.zh.md | 2 +- cmd/picoclaw/internal/gateway/helpers.go | 18 +++++ pkg/agent/loop.go | 64 ++++++++++++++++ pkg/voice/transcriber.go | 5 ++ pkg/voice/transcriber_test.go | 97 ++++++++++++++++++++++++ 10 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 pkg/voice/transcriber_test.go diff --git a/README.fr.md b/README.fr.md index c452b71ac..87eaca0e8 100644 --- a/README.fr.md +++ b/README.fr.md @@ -772,7 +772,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique ### Fournisseurs > [!NOTE] -> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits. +> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent. | Fournisseur | Utilisation | Obtenir une Clé API | | ------------------------ | ---------------------------------------- | ------------------------------------------------------ | diff --git a/README.ja.md b/README.ja.md index 6d5d09451..bb8d33fae 100644 --- a/README.ja.md +++ b/README.ja.md @@ -728,7 +728,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る ### プロバイダー > [!NOTE] -> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。 +> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。 | プロバイダー | 用途 | API キー取得先 | | --- | --- | --- | diff --git a/README.md b/README.md index b040d0605..5b39204c7 100644 --- a/README.md +++ b/README.md @@ -818,7 +818,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate ### Providers > [!NOTE] -> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. +> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level. | Provider | Purpose | Get API Key | | -------------------------- | --------------------------------------- | -------------------------------------------------------------------- | diff --git a/README.pt-br.md b/README.pt-br.md index 61663e363..6752124d0 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -766,7 +766,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com ### Provedores > [!NOTE] -> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas. +> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente. | Provedor | Finalidade | Obter API Key | | --- | --- | --- | diff --git a/README.vi.md b/README.vi.md index f8ece7eda..161a96dd7 100644 --- a/README.vi.md +++ b/README.vi.md @@ -740,7 +740,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và ### Nhà cung cấp (Providers) > [!NOTE] -> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản. +> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent. | Nhà cung cấp | Mục đích | Lấy API Key | | --- | --- | --- | diff --git a/README.zh.md b/README.zh.md index 7c9351cb4..f39526250 100644 --- a/README.zh.md +++ b/README.zh.md @@ -418,7 +418,7 @@ Agent 读取 HEARTBEAT.md ### 提供商 (Providers) > [!NOTE] -> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。 +> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。 | 提供商 | 用途 | 获取 API Key | | -------------------- | ---------------------------- | -------------------------------------------------------------------- | diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 747f7d44e..c4a6f59fe 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" @@ -36,6 +37,7 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/voice" ) func gatewayCmd(debug bool) error { @@ -134,6 +136,22 @@ func gatewayCmd(debug bool) error { agentLoop.SetChannelManager(channelManager) agentLoop.SetMediaStore(mediaStore) + // Wire up voice transcription if Groq API key is available + groqAPIKey := cfg.Providers.Groq.APIKey + if groqAPIKey == "" { + for _, mc := range cfg.ModelList { + if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { + groqAPIKey = mc.APIKey + break + } + } + } + if groqAPIKey != "" { + transcriber := voice.NewGroqTranscriber(groqAPIKey) + agentLoop.SetTranscriber(transcriber) + logger.InfoC("voice", "Groq voice transcription enabled (agent-level)") + } + enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a72f95bb1..0a2633d90 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -18,6 +18,8 @@ import ( "time" "unicode/utf8" + "regexp" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -30,6 +32,7 @@ import ( "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/voice" ) type AgentLoop struct { @@ -42,6 +45,7 @@ type AgentLoop struct { fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore + transcriber voice.Transcriber } // processOptions configures how a message is processed @@ -262,6 +266,64 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s } +// SetTranscriber injects a voice transcriber for agent-level audio transcription. +func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { + al.transcriber = t +} + +var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) + +// transcribeAudioInMessage resolves audio media refs, transcribes them, and +// replaces audio annotations in msg.Content with the transcribed text. +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage { + if al.transcriber == nil || !al.transcriber.IsAvailable() || al.mediaStore == nil || len(msg.Media) == 0 { + return msg + } + + // Transcribe each audio media ref in order. + var transcriptions []string + for _, ref := range msg.Media { + path, meta, err := al.mediaStore.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + continue + } + if !utils.IsAudioFile(meta.Filename, meta.ContentType) { + continue + } + result, err := al.transcriber.Transcribe(ctx, path) + if err != nil { + logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) + transcriptions = append(transcriptions, "") + continue + } + transcriptions = append(transcriptions, result.Text) + } + + if len(transcriptions) == 0 { + return msg + } + + // Replace audio annotations sequentially with transcriptions. + idx := 0 + newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { + if idx >= len(transcriptions) { + return match + } + text := transcriptions[idx] + idx++ + return "[voice: " + text + "]" + }) + + // Append any remaining transcriptions not matched by an annotation. + for ; idx < len(transcriptions); idx++ { + newContent += "\n[voice: " + transcriptions[idx] + "]" + } + + msg.Content = newContent + return msg +} + // inferMediaType determines the media type ("image", "audio", "video", "file") // from a filename and MIME content type. func inferMediaType(filename, contentType string) string { @@ -364,6 +426,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "session_key": msg.SessionKey, }) + msg = al.transcribeAudioInMessage(ctx, msg) + // Route system messages to processSystemMessage if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index f973e77fe..bf48d0fda 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -16,6 +16,11 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +type Transcriber interface { + Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) + IsAvailable() bool +} + type GroqTranscriber struct { apiKey string apiBase string diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go new file mode 100644 index 000000000..c4755dd54 --- /dev/null +++ b/pkg/voice/transcriber_test.go @@ -0,0 +1,97 @@ +package voice + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// Ensure GroqTranscriber satisfies the Transcriber interface at compile time. +var _ Transcriber = (*GroqTranscriber)(nil) + +func TestIsAvailable(t *testing.T) { + tests := []struct { + name string + apiKey string + want bool + }{ + {"with key", "sk-test-key", true}, + {"empty key", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tr := NewGroqTranscriber(tc.apiKey) + if got := tr.IsAvailable(); got != tc.want { + t.Errorf("IsAvailable() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestTranscribe(t *testing.T) { + // Write a minimal fake audio file so the transcriber can open and send it. + tmpDir := t.TempDir() + audioPath := filepath.Join(tmpDir, "clip.ogg") + if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil { + t.Fatalf("failed to write fake audio file: %v", err) + } + + t.Run("success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/audio/transcriptions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer sk-test" { + t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{ + Text: "hello world", + Language: "en", + Duration: 1.5, + }) + })) + defer srv.Close() + + tr := NewGroqTranscriber("sk-test") + tr.apiBase = srv.URL + + resp, err := tr.Transcribe(context.Background(), audioPath) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if resp.Text != "hello world" { + t.Errorf("Text = %q, want %q", resp.Text, "hello world") + } + if resp.Language != "en" { + t.Errorf("Language = %q, want %q", resp.Language, "en") + } + }) + + t.Run("api error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + tr := NewGroqTranscriber("sk-bad") + tr.apiBase = srv.URL + + _, err := tr.Transcribe(context.Background(), audioPath) + if err == nil { + t.Fatal("expected error for non-200 response, got nil") + } + }) + + t.Run("missing file", func(t *testing.T) { + tr := NewGroqTranscriber("sk-test") + _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) +} From b74f92ed28bc4f14641cc993311d928034f10a87 Mon Sep 17 00:00:00 2001 From: Dimitrij Denissenko Date: Sun, 1 Mar 2026 21:02:16 +0000 Subject: [PATCH 068/132] A more neutral and elegant voice.Transcriber interface --- cmd/picoclaw/internal/gateway/helpers.go | 17 +---- pkg/agent/loop.go | 2 +- pkg/voice/transcriber.go | 26 ++++++-- pkg/voice/transcriber_test.go | 85 +++++++++++++++++++++--- 4 files changed, 99 insertions(+), 31 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index c4a6f59fe..5225340c7 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -7,7 +7,6 @@ import ( "os" "os/signal" "path/filepath" - "strings" "time" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" @@ -136,20 +135,10 @@ func gatewayCmd(debug bool) error { agentLoop.SetChannelManager(channelManager) agentLoop.SetMediaStore(mediaStore) - // Wire up voice transcription if Groq API key is available - groqAPIKey := cfg.Providers.Groq.APIKey - if groqAPIKey == "" { - for _, mc := range cfg.ModelList { - if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { - groqAPIKey = mc.APIKey - break - } - } - } - if groqAPIKey != "" { - transcriber := voice.NewGroqTranscriber(groqAPIKey) + // Wire up voice transcription if a supported provider is configured. + if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { agentLoop.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq voice transcription enabled (agent-level)") + logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) } enabledChannels := channelManager.GetEnabledChannels() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0a2633d90..f37d419b1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -276,7 +276,7 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and // replaces audio annotations in msg.Content with the transcribed text. func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage { - if al.transcriber == nil || !al.transcriber.IsAvailable() || al.mediaStore == nil || len(msg.Media) == 0 { + if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { return msg } diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index bf48d0fda..e949d7a22 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -10,15 +10,17 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) type Transcriber interface { + Name() string Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) - IsAvailable() bool } type GroqTranscriber struct { @@ -157,8 +159,22 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) return &result, nil } -func (t *GroqTranscriber) IsAvailable() bool { - available := t.apiKey != "" - logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available}) - return available +func (t *GroqTranscriber) Name() string { + return "groq" +} + +// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or +// nil if no supported transcription provider is configured. +func DetectTranscriber(cfg *config.Config) Transcriber { + // Direct Groq provider config takes priority. + if key := cfg.Providers.Groq.APIKey; key != "" { + return NewGroqTranscriber(key) + } + // Fall back to any model-list entry that uses the groq/ protocol. + for _, mc := range cfg.ModelList { + if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { + return NewGroqTranscriber(mc.APIKey) + } + } + return nil } diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go index c4755dd54..6a28b3664 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/voice/transcriber_test.go @@ -8,25 +8,88 @@ import ( "os" "path/filepath" "testing" + + "github.com/sipeed/picoclaw/pkg/config" ) // Ensure GroqTranscriber satisfies the Transcriber interface at compile time. var _ Transcriber = (*GroqTranscriber)(nil) -func TestIsAvailable(t *testing.T) { - tests := []struct { - name string - apiKey string - want bool - }{ - {"with key", "sk-test-key", true}, - {"empty key", "", false}, +func TestGroqTranscriberName(t *testing.T) { + tr := NewGroqTranscriber("sk-test") + if got := tr.Name(); got != "groq" { + t.Errorf("Name() = %q, want %q", got, "groq") } +} + +func TestDetectTranscriber(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + wantNil bool + wantName string + }{ + { + name: "no config", + cfg: &config.Config{}, + wantNil: true, + }, + { + name: "groq provider key", + cfg: &config.Config{ + Providers: config.ProvidersConfig{ + Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, + }, + }, + wantName: "groq", + }, + { + name: "groq via model list", + cfg: &config.Config{ + ModelList: []config.ModelConfig{ + {Model: "openai/gpt-4o", APIKey: "sk-openai"}, + {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, + }, + }, + wantName: "groq", + }, + { + name: "groq model list entry without key is skipped", + cfg: &config.Config{ + ModelList: []config.ModelConfig{ + {Model: "groq/llama-3.3-70b", APIKey: ""}, + }, + }, + wantNil: true, + }, + { + name: "provider key takes priority over model list", + cfg: &config.Config{ + Providers: config.ProvidersConfig{ + Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, + }, + ModelList: []config.ModelConfig{ + {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, + }, + }, + wantName: "groq", + }, + } + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - tr := NewGroqTranscriber(tc.apiKey) - if got := tr.IsAvailable(); got != tc.want { - t.Errorf("IsAvailable() = %v, want %v", got, tc.want) + tr := DetectTranscriber(tc.cfg) + if tc.wantNil { + if tr != nil { + t.Errorf("DetectTranscriber() = %v, want nil", tr) + } + return + } + if tr == nil { + t.Fatal("DetectTranscriber() = nil, want non-nil") + } + if got := tr.Name(); got != tc.wantName { + t.Errorf("Name() = %q, want %q", got, tc.wantName) } }) } From 3d54a77c407404e2043fa3af568fef5321a21ad8 Mon Sep 17 00:00:00 2001 From: Zachary Guerrero Date: Fri, 20 Feb 2026 14:32:58 -0800 Subject: [PATCH 069/132] 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 070/132] 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 071/132] 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 6caee427bb4c4556ff06c17ded8b36a4c4364e8b Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 13:25:37 +0800 Subject: [PATCH 072/132] Add WeCom AIBot channel implementation and tests - Introduced WeCom AIBot channel configuration in config.go with relevant fields. - Implemented WeCom AIBot channel factory registration in init.go. - Created unit tests for WeCom AIBot channel functionalities including initialization, start/stop behavior, webhook path handling, message encryption/decryption, and signature generation. - Set default values for WeCom AIBot configuration in defaults.go. --- config/config.example.json | 13 +- pkg/channels/manager.go | 4 + pkg/channels/wecom/aibot.go | 1143 ++++++++++++++++++++++++++++++ pkg/channels/wecom/aibot_test.go | 218 ++++++ pkg/channels/wecom/init.go | 3 + pkg/config/config.go | 43 +- pkg/config/defaults.go | 12 + 7 files changed, 1421 insertions(+), 15 deletions(-) create mode 100644 pkg/channels/wecom/aibot.go create mode 100644 pkg/channels/wecom/aibot_test.go diff --git a/config/config.example.json b/config/config.example.json index d885ef94b..df72a876e 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -127,7 +127,7 @@ "reasoning_channel_id": "" }, "wecom": { - "_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats", + "_comment": "WeCom Bot - Easier setup, supports group chats", "enabled": false, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", @@ -149,6 +149,17 @@ "allow_from": [], "reply_timeout": 5, "reasoning_channel_id": "" + }, + "wecom_aibot": { + "_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.", + "enabled": false, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "max_steps": 10, + "welcome_message": "Hello! I'm your AI assistant. How can I help you today?" } }, "providers": { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 155e50b39..21fcddd09 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -255,6 +255,10 @@ func (m *Manager) initChannels() error { m.initChannel("wecom", "WeCom") } + if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" { + m.initChannel("wecom_aibot", "WeCom AI Bot") + } + if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" { m.initChannel("wecom_app", "WeCom App") } diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go new file mode 100644 index 000000000..c2f98806b --- /dev/null +++ b/pkg/channels/wecom/aibot.go @@ -0,0 +1,1143 @@ +package wecom + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人) +type WeComAIBotChannel struct { + *channels.BaseChannel + config config.WeComAIBotConfig + ctx context.Context + cancel context.CancelFunc + streamTasks map[string]*streamTask // streamID -> task (for poll lookups) + chatTasks map[string][]*streamTask // chatID -> in-flight tasks queue (FIFO) + taskMu sync.RWMutex +} + +// streamTask represents a streaming task for AI Bot +type streamTask struct { + StreamID string + ChatID string // used by Send() to find this task + ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) + Question string + CreatedTime time.Time + Deadline time.Time // ~5m30s, we close the stream here and switch to response_url + StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url + Finished bool // fully done + mu sync.Mutex + answerCh chan string // receives agent reply from Send() +} + +// WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot +// Ref: https://developer.work.weixin.qq.com/document/path/100719 +type WeComAIBotMessage struct { + MsgID string `json:"msgid"` + AIBotID string `json:"aibotid"` + ChatID string `json:"chatid"` // only for group chat + ChatType string `json:"chattype"` // "single" or "group" + From struct { + UserID string `json:"userid"` + } `json:"from"` + ResponseURL string `json:"response_url"` // temporary URL for proactive reply + MsgType string `json:"msgtype"` + // text message + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + // stream polling refresh + Stream *struct { + ID string `json:"id"` + } `json:"stream,omitempty"` + // image message + Image *struct { + URL string `json:"url"` + } `json:"image,omitempty"` + // mixed message (text + image) + Mixed *struct { + MsgItem []struct { + MsgType string `json:"msgtype"` + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + Image *struct { + URL string `json:"url"` + } `json:"image,omitempty"` + } `json:"msg_item"` + } `json:"mixed,omitempty"` + // event field + Event *struct { + EventType string `json:"eventtype"` + } `json:"event,omitempty"` +} + +// WeComAIBotStreamResponse represents the streaming response format +type WeComAIBotStreamResponse struct { + MsgType string `json:"msgtype"` + Stream struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + } `json:"stream"` +} + +// WeComAIBotEncryptedResponse represents the encrypted response wrapper +// Fields match WXBizJsonMsgCrypt.generate() in Python SDK +type WeComAIBotEncryptedResponse struct { + Encrypt string `json:"encrypt"` + MsgSignature string `json:"msgsignature"` + Timestamp string `json:"timestamp"` + Nonce string `json:"nonce"` +} + +// NewWeComAIBotChannel creates a new WeCom AI Bot channel instance +func NewWeComAIBotChannel( + cfg config.WeComAIBotConfig, + messageBus *bus.MessageBus, +) (*WeComAIBotChannel, error) { + if cfg.Token == "" || cfg.EncodingAESKey == "" { + return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot") + } + + base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(2048), + ) + + return &WeComAIBotChannel{ + BaseChannel: base, + config: cfg, + streamTasks: make(map[string]*streamTask), + chatTasks: make(map[string][]*streamTask), + }, nil +} + +// Name returns the channel name +func (c *WeComAIBotChannel) Name() string { + return "wecom_aibot" +} + +// Start initializes the WeCom AI Bot channel +func (c *WeComAIBotChannel) Start(ctx context.Context) error { + logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel...") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Start cleanup goroutine for old tasks + go c.cleanupLoop() + + c.SetRunning(true) + logger.InfoC("wecom_aibot", "WeCom AI Bot channel started") + + return nil +} + +// Stop gracefully stops the WeCom AI Bot channel +func (c *WeComAIBotChannel) Stop(ctx context.Context) error { + logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel...") + + if c.cancel != nil { + c.cancel() + } + + c.SetRunning(false) + logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped") + return nil +} + +// Send delivers the agent reply into the active streamTask for msg.ChatID. +// It writes into the earliest unfinished task in the queue (FIFO per chatID). +// If the stream has already closed (deadline passed), it posts directly to response_url. +func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + c.taskMu.Lock() + queue := c.chatTasks[msg.ChatID] + for len(queue) > 0 && queue[0].Finished { + queue = queue[1:] + } + c.chatTasks[msg.ChatID] = queue + var task *streamTask + if len(queue) > 0 { + task = queue[0] + } + c.taskMu.Unlock() + + if task == nil { + logger.DebugCF( + "wecom_aibot", + "Send: no active task for chat (may have timed out)", + map[string]any{ + "chat_id": msg.ChatID, + }, + ) + return nil + } + + task.mu.Lock() + streamClosed := task.StreamClosed + responseURL := task.ResponseURL + task.mu.Unlock() + + if streamClosed { + // Stream already ended with a "please wait" notice; send the real reply via response_url. + logger.InfoCF("wecom_aibot", "Sending reply via response_url", map[string]any{ + "stream_id": task.StreamID, + "chat_id": msg.ChatID, + }) + if responseURL != "" { + if err := c.sendViaResponseURL(responseURL, msg.Content); err != nil { + logger.ErrorCF("wecom_aibot", "Failed to send via response_url", map[string]any{ + "error": err, + "stream_id": task.StreamID, + }) + } + } else { + logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ + "stream_id": task.StreamID, + }) + } + c.removeTask(task) + return nil + } + + // Stream still open: deliver via answerCh for the next poll response. + select { + case task.answerCh <- msg.Content: + case <-ctx.Done(): + return ctx.Err() + } + return nil +} + +// WebhookPath returns the path for registering on the shared HTTP server +func (c *WeComAIBotChannel) WebhookPath() string { + if c.config.WebhookPath == "" { + return "/webhook/wecom-aibot" + } + return c.config.WebhookPath +} + +// ServeHTTP implements http.Handler for the shared HTTP server +func (c *WeComAIBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.handleWebhook(w, r) +} + +// HealthPath returns the health check endpoint path +func (c *WeComAIBotChannel) HealthPath() string { + return c.WebhookPath() + "/health" +} + +// HealthHandler handles health check requests +func (c *WeComAIBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + c.handleHealth(w, r) +} + +// handleWebhook handles incoming webhook requests from WeCom AI Bot +func (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Log all incoming requests for debugging + logger.DebugCF("wecom_aibot", "Received webhook request", map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.RawQuery, + }) + + if r.Method == http.MethodGet { + // URL verification + c.handleVerification(ctx, w, r) + } else if r.Method == http.MethodPost { + // Message callback + c.handleMessageCallback(ctx, w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleVerification handles the URL verification request from WeCom +func (c *WeComAIBotChannel) handleVerification( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, +) { + msgSignature := r.URL.Query().Get("msg_signature") + timestamp := r.URL.Query().Get("timestamp") + nonce := r.URL.Query().Get("nonce") + echostr := r.URL.Query().Get("echostr") + + logger.DebugCF("wecom_aibot", "URL verification request", map[string]any{ + "msg_signature": msgSignature, + "timestamp": timestamp, + "nonce": nonce, + }) + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + logger.ErrorC("wecom_aibot", "Signature verification failed") + http.Error(w, "Signature verification failed", http.StatusUnauthorized) + return + } + + // Decrypt echostr + // For WeCom AI Bot (智能机器人), receiveid should be empty string + decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{ + "error": err, + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + // Remove BOM and whitespace as per WeCom documentation + decrypted = strings.TrimPrefix(decrypted, "\ufeff") + decrypted = strings.TrimSpace(decrypted) + + logger.InfoC("wecom_aibot", "URL verification successful") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(decrypted)) +} + +// handleMessageCallback handles incoming messages from WeCom AI Bot +func (c *WeComAIBotChannel) handleMessageCallback( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, +) { + msgSignature := r.URL.Query().Get("msg_signature") + timestamp := r.URL.Query().Get("timestamp") + nonce := r.URL.Query().Get("nonce") + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to read request body", map[string]any{ + "error": err, + }) + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + // Parse JSON body to get encrypted message + // Format: {"encrypt": "base64_encrypted_string"} + var encryptedMsg struct { + Encrypt string `json:"encrypt"` + } + if err := json.Unmarshal(body, &encryptedMsg); err != nil { + logger.ErrorCF("wecom_aibot", "Failed to parse JSON body", map[string]any{ + "error": err, + "body": string(body), + }) + http.Error(w, "Failed to parse JSON", http.StatusBadRequest) + return + } + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + logger.ErrorC("wecom_aibot", "Signature verification failed") + http.Error(w, "Signature verification failed", http.StatusUnauthorized) + return + } + + // Decrypt message + // For WeCom AI Bot (智能机器人), receiveid is empty string + decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{ + "error": err, + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + // Parse decrypted JSON message + var msg WeComAIBotMessage + if unmarshalErr := json.Unmarshal([]byte(decrypted), &msg); unmarshalErr != nil { + logger.ErrorCF("wecom_aibot", "Failed to parse decrypted JSON", map[string]any{ + "error": unmarshalErr, + "decrypted": decrypted, + }) + http.Error(w, "Failed to parse message", http.StatusInternalServerError) + return + } + + logger.DebugCF("wecom_aibot", "Decrypted message", map[string]any{ + "msgtype": msg.MsgType, + }) + + // Process the message and get streaming response + response := c.processMessage(ctx, msg, timestamp, nonce) + + // Return encrypted JSON response + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) +} + +// processMessage processes the received message and returns encrypted response +func (c *WeComAIBotChannel) processMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + logger.DebugCF("wecom_aibot", "Processing message", map[string]any{ + "msgtype": msg.MsgType, + }) + + switch msg.MsgType { + case "text": + return c.handleTextMessage(ctx, msg, timestamp, nonce) + case "stream": + return c.handleStreamMessage(ctx, msg, timestamp, nonce) + case "image": + return c.handleImageMessage(ctx, msg, timestamp, nonce) + case "mixed": + return c.handleMixedMessage(ctx, msg, timestamp, nonce) + case "event": + return c.handleEventMessage(ctx, msg, timestamp, nonce) + default: + logger.WarnCF("wecom_aibot", "Unsupported message type", map[string]any{ + "msgtype": msg.MsgType, + }) + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: "Unsupported message type: " + msg.MsgType, + }, + }) + } +} + +// handleTextMessage handles text messages by starting a new streaming task +func (c *WeComAIBotChannel) handleTextMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + if msg.Text == nil { + logger.ErrorC("wecom_aibot", "text message missing text field") + return c.encryptEmptyResponse(timestamp, nonce) + } + + content := msg.Text.Content + userID := msg.From.UserID + if userID == "" { + userID = "unknown" + } + + // chatID: group chat uses chatid, single chat uses userid + chatID := msg.ChatID + if chatID == "" { + chatID = userID + } + + streamID := c.generateStreamID() + + // WeCom stops sending stream-refresh callbacks after 6 minutes. + // Set a slightly shorter deadline so we can send a timeout notice before it gives up. + deadline := time.Now().Add(30 * time.Second) + + task := &streamTask{ + StreamID: streamID, + ChatID: chatID, + ResponseURL: msg.ResponseURL, + Question: content, + CreatedTime: time.Now(), + Deadline: deadline, + Finished: false, + answerCh: make(chan string, 1), + } + + c.taskMu.Lock() + c.streamTasks[streamID] = task + c.chatTasks[chatID] = append(c.chatTasks[chatID], task) + c.taskMu.Unlock() + + // Publish to agent asynchronously; agent will call Send() with reply + // Use c.ctx (channel lifetime) instead of r.Context() which is canceled when the HTTP handler returns. + go func() { + sender := bus.SenderInfo{ + Platform: "wecom_aibot", + PlatformID: userID, + CanonicalID: "wecom_aibot:" + userID, + DisplayName: userID, + } + peerKind := "direct" + if msg.ChatType == "group" { + peerKind = "group" + } + peer := bus.Peer{Kind: peerKind, ID: chatID} + metadata := map[string]string{ + "channel": "wecom_aibot", + "chat_type": msg.ChatType, + "msg_type": "text", + "msgid": msg.MsgID, + "aibotid": msg.AIBotID, + "stream_id": streamID, + "response_url": msg.ResponseURL, + } + c.HandleMessage(c.ctx, peer, msg.MsgID, userID, chatID, + content, nil, metadata, sender) + }() + + // Return first streaming response immediately (finish=false, content empty) + return c.getStreamResponse(task, timestamp, nonce) +} + +// handleStreamMessage handles stream polling requests +func (c *WeComAIBotChannel) handleStreamMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + if msg.Stream == nil { + logger.ErrorC("wecom_aibot", "Stream message missing stream field") + return c.encryptEmptyResponse(timestamp, nonce) + } + + streamID := msg.Stream.ID + + c.taskMu.RLock() + task, exists := c.streamTasks[streamID] + c.taskMu.RUnlock() + + if !exists { + logger.DebugCF( + "wecom_aibot", + "Stream task not found (may be from previous session)", + map[string]any{ + "stream_id": streamID, + }, + ) + return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: streamID, + Finish: true, + Content: "Task not found or already finished. Please resend your message to start a new session.", + }, + }) + } + + // Get next response + return c.getStreamResponse(task, timestamp, nonce) +} + +// handleImageMessage handles image messages +func (c *WeComAIBotChannel) handleImageMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + logger.WarnC("wecom_aibot", "Image message type not yet fully implemented") + if msg.Image == nil { + logger.ErrorC("wecom_aibot", "Image message missing image field") + return c.encryptEmptyResponse(timestamp, nonce) + } + + imageURL := msg.Image.URL + + // Download and decrypt image + _, err := c.downloadAndDecryptImage(ctx, imageURL) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{ + "error": err, + "url": imageURL, + }) + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: fmt.Sprintf( + "Image received (URL: %s), but image messages are not yet supported", + imageURL, + ), + }, + }) + } + + // Echo back the image (simple demo behavior) + // streamID := c.generateStreamID() + // return c.encryptImageResponse(streamID, timestamp, nonce, imageData) + + // For now, just acknowledge receipt without echoing the image + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: fmt.Sprintf( + "Image received (URL: %s), but image messages are not yet supported", + imageURL, + ), + }, + }) +} + +// handleMixedMessage handles mixed (text + image) messages +func (c *WeComAIBotChannel) handleMixedMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented") + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: "Mixed message type is not yet supported", + }, + }) +} + +// handleEventMessage handles event messages +func (c *WeComAIBotChannel) handleEventMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + eventType := "" + if msg.Event != nil { + eventType = msg.Event.EventType + } + logger.DebugCF("wecom_aibot", "Received event", map[string]any{ + "event_type": eventType, + }) + + // Send welcome message when user opens the chat window + if eventType == "enter_chat" && c.config.WelcomeMessage != "" { + streamID := c.generateStreamID() + return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: streamID, + Finish: true, + Content: c.config.WelcomeMessage, + }, + }) + } + + return c.encryptEmptyResponse(timestamp, nonce) +} + +// getStreamResponse gets the next streaming response for a task. +// - If agent replied: return finish=true with the real answer. +// - If deadline passed: return finish=true with a "please wait" notice, keep task alive for response_url. +// - Otherwise: return finish=false (empty), client will poll again. +func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce string) string { + var content string + var finish bool + var closeStreamOnly bool // close stream but do NOT remove task (response_url still pending) + + select { + case answer := <-task.answerCh: + // Agent replied before deadline — normal finish. + content = answer + finish = true + default: + if time.Now().After(task.Deadline) { + // Deadline reached: close the stream with a notice, then wait for agent via response_url. + content = "⏳ Processing, please wait. The results will be sent shortly." + finish = true + closeStreamOnly = true + logger.InfoCF( + "wecom_aibot", + "Stream deadline reached, switching to response_url mode", + map[string]any{ + "stream_id": task.StreamID, + "chat_id": task.ChatID, + "response_url": task.ResponseURL != "", + }, + ) + } + // else: still waiting, return finish=false + } + + if finish && !closeStreamOnly { + // Normal finish: remove from all maps. + c.removeTask(task) + } else if closeStreamOnly { + // Only mark stream as closed; keep in chatTasks for Send() to find. + task.mu.Lock() + task.StreamClosed = true + task.mu.Unlock() + // Remove from streamTasks (no more stream polls expected). + c.taskMu.Lock() + delete(c.streamTasks, task.StreamID) + c.taskMu.Unlock() + } + + response := WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: task.StreamID, + Finish: finish, + Content: content, + }, + } + + return c.encryptResponse(task.StreamID, timestamp, nonce, response) +} + +// removeTask removes a task from both streamTasks and chatTasks and marks it finished. +func (c *WeComAIBotChannel) removeTask(task *streamTask) { + task.mu.Lock() + task.Finished = true + task.mu.Unlock() + + c.taskMu.Lock() + delete(c.streamTasks, task.StreamID) + queue := c.chatTasks[task.ChatID] + for i, t := range queue { + if t == task { + c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) + break + } + } + if len(c.chatTasks[task.ChatID]) == 0 { + delete(c.chatTasks, task.ChatID) + } + c.taskMu.Unlock() +} + +// sendViaResponseURL posts a markdown reply to the WeCom response_url. +// response_url is valid for 1 hour and can only be used once per callback. +func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error { + payload := map[string]any{ + "msgtype": "markdown", + "markdown": map[string]string{ + "content": content, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to post to response_url: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("response_url returned %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// encryptResponse encrypts a streaming response +func (c *WeComAIBotChannel) encryptResponse( + streamID, timestamp, nonce string, + response WeComAIBotStreamResponse, +) string { + // Marshal response to JSON + plaintext, err := json.Marshal(response) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to marshal response", map[string]any{ + "error": err, + }) + return "" + } + + logger.DebugCF("wecom_aibot", "Encrypting response", map[string]any{ + "stream_id": streamID, + "finish": response.Stream.Finish, + "preview": utils.Truncate(response.Stream.Content, 100), + }) + + // Encrypt message + encrypted, err := c.encryptMessage(string(plaintext), "") + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to encrypt message", map[string]any{ + "error": err, + }) + return "" + } + + // Generate signature + signature := c.generateSignature(timestamp, nonce, encrypted) + + // Build encrypted response + encryptedResp := WeComAIBotEncryptedResponse{ + Encrypt: encrypted, + MsgSignature: signature, + Timestamp: timestamp, + Nonce: nonce, + } + + respJSON, err := json.Marshal(encryptedResp) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to marshal encrypted response", map[string]any{ + "error": err, + }) + return "" + } + + logger.DebugCF("wecom_aibot", "Response encrypted", map[string]any{ + "stream_id": streamID, + }) + + return string(respJSON) +} + +// encryptEmptyResponse returns empty encrypted response +func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string { + return "" +} + +// encryptMessage encrypts a plain text message for WeCom AI Bot +func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) { + // Decode AES key + aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + if err != nil { + return "", fmt.Errorf("failed to decode AES key: %w", err) + } + + if len(aesKey) != 32 { + return "", fmt.Errorf("invalid AES key length: %d", len(aesKey)) + } + + // Generate 16-byte random string + randomBytes := make([]byte, 16) + for i := range 16 { + n, randErr := rand.Int(rand.Reader, big.NewInt(10)) + if randErr != nil { + return "", fmt.Errorf("failed to generate random: %w", randErr) + } + randomBytes[i] = byte('0' + n.Int64()) + } + + // Build message: random(16) + msg_len(4) + msg + receiveid + plaintextBytes := []byte(plaintext) + receiveidBytes := []byte(receiveid) + + msgLen := uint32(len(plaintextBytes)) + msgLenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(msgLenBytes, msgLen) + + // Concatenate + var buffer bytes.Buffer + buffer.Write(randomBytes) + buffer.Write(msgLenBytes) + buffer.Write(plaintextBytes) + buffer.Write(receiveidBytes) + + // PKCS7 padding + plainData := buffer.Bytes() + plainData = pkcs7Pad(plainData, blockSize) + + // AES-CBC encrypt + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + ciphertext := make([]byte, len(plainData)) + iv := aesKey[:aes.BlockSize] + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ciphertext, plainData) + + // Base64 encode + encoded := base64.StdEncoding.EncodeToString(ciphertext) + + return encoded, nil +} + +// pkcs7Pad adds PKCS7 padding +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - (len(data) % blockSize) + if padding == 0 { + padding = blockSize + } + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +// generateSignature generates message signature using common function +func (c *WeComAIBotChannel) generateSignature(timestamp, nonce, encrypt string) string { + // Sort parameters + params := []string{c.config.Token, timestamp, nonce, encrypt} + sort.Strings(params) + + // Concatenate + str := strings.Join(params, "") + + // SHA1 hash + hash := sha1.Sum([]byte(str)) + return fmt.Sprintf("%x", hash) +} + +// generateStreamID generates a random stream ID +func (c *WeComAIBotChannel) generateStreamID() string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 10) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + b[i] = letters[n.Int64()] + } + return string(b) +} + +// downloadAndDecryptImage downloads and decrypts an encrypted image +func (c *WeComAIBotChannel) downloadAndDecryptImage( + ctx context.Context, + imageURL string, +) ([]byte, error) { + // Download image + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + encryptedData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read image data: %w", err) + } + + logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ + "size": len(encryptedData), + }) + + // Decode AES key + aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + if err != nil { + return nil, fmt.Errorf("failed to decode AES key: %w", err) + } + + if len(aesKey) != 32 { + return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) + } + + // Decrypt image (AES-CBC) + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + + if len(encryptedData)%aes.BlockSize != 0 { + return nil, fmt.Errorf("encrypted data size not multiple of block size") + } + + iv := aesKey[:aes.BlockSize] + mode := cipher.NewCBCDecrypter(block, iv) + + decryptedData := make([]byte, len(encryptedData)) + mode.CryptBlocks(decryptedData, encryptedData) + + // Remove PKCS7 padding + decryptedData, err = pkcs7Unpad(decryptedData) + if err != nil { + return nil, fmt.Errorf("failed to unpad: %w", err) + } + + logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ + "size": len(decryptedData), + }) + + return decryptedData, nil +} + +// cleanupLoop periodically cleans up old streaming tasks +func (c *WeComAIBotChannel) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.cleanupOldTasks() + case <-c.ctx.Done(): + return + } + } +} + +// cleanupOldTasks removes tasks that have been alive longer than 1 hour +// (response_url validity window), which is the absolute maximum lifetime of any task. +func (c *WeComAIBotChannel) cleanupOldTasks() { + c.taskMu.Lock() + defer c.taskMu.Unlock() + + cutoff := time.Now().Add(-1 * time.Hour) + for id, task := range c.streamTasks { + if task.CreatedTime.Before(cutoff) { + delete(c.streamTasks, id) + queue := c.chatTasks[task.ChatID] + for i, t := range queue { + if t == task { + c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) + break + } + } + if len(c.chatTasks[task.ChatID]) == 0 { + delete(c.chatTasks, task.ChatID) + } + logger.DebugCF("wecom_aibot", "Cleaned up expired task", map[string]any{ + "stream_id": id, + }) + } + } + // Also clean up StreamClosed tasks from chatTasks that are older than 1 hour. + for chatID, queue := range c.chatTasks { + filtered := queue[:0] + for _, t := range queue { + if !t.Finished && t.CreatedTime.After(cutoff) { + filtered = append(filtered, t) + } + } + if len(filtered) == 0 { + delete(c.chatTasks, chatID) + } else { + c.chatTasks[chatID] = filtered + } + } +} + +// handleHealth handles health check requests +func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { + status := "ok" + if !c.IsRunning() { + status = "not running" + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": status, + }) +} diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go new file mode 100644 index 000000000..7fb90f22e --- /dev/null +++ b/pkg/channels/wecom/aibot_test.go @@ -0,0 +1,218 @@ +package wecom + +import ( + "context" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewWeComAIBotChannel(t *testing.T) { + t.Run("success with valid config", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + WebhookPath: "/webhook/test", + } + + messageBus := bus.NewMessageBus() + ch, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if ch == nil { + t.Fatal("Expected channel to be created") + } + + if ch.Name() != "wecom_aibot" { + t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name()) + } + }) + + t.Run("error with missing token", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + _, err := NewWeComAIBotChannel(cfg, messageBus) + + if err == nil { + t.Fatal("Expected error for missing token, got nil") + } + }) + + t.Run("error with missing encoding key", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + } + + messageBus := bus.NewMessageBus() + _, err := NewWeComAIBotChannel(cfg, messageBus) + + if err == nil { + t.Fatal("Expected error for missing encoding key, got nil") + } + }) +} + +func TestWeComAIBotChannelStartStop(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Failed to create channel: %v", err) + } + + ctx := context.Background() + + // Test Start + if err := ch.Start(ctx); err != nil { + t.Fatalf("Failed to start channel: %v", err) + } + + if !ch.IsRunning() { + t.Error("Expected channel to be running") + } + + // Test Stop + if err := ch.Stop(ctx); err != nil { + t.Fatalf("Failed to stop channel: %v", err) + } + + if ch.IsRunning() { + t.Error("Expected channel to be stopped") + } +} + +func TestWeComAIBotChannelWebhookPath(t *testing.T) { + t.Run("default path", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + expectedPath := "/webhook/wecom-aibot" + if ch.WebhookPath() != expectedPath { + t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath()) + } + }) + + t.Run("custom path", func(t *testing.T) { + customPath := "/custom/webhook" + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + WebhookPath: customPath, + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + if ch.WebhookPath() != customPath { + t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath()) + } + }) +} + +func TestGenerateStreamID(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + // Generate multiple IDs and check they are unique + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id := ch.generateStreamID() + + if len(id) != 10 { + t.Errorf("Expected stream ID length 10, got %d", len(id)) + } + + if ids[id] { + t.Errorf("Duplicate stream ID generated: %s", id) + } + ids[id] = true + } +} + +func TestEncryptDecrypt(t *testing.T) { + // Use a valid 43-character base64 key (企业微信标准格式) + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + plaintext := "Hello, World!" + receiveid := "" + + // Encrypt + encrypted, err := ch.encryptMessage(plaintext, receiveid) + if err != nil { + t.Fatalf("Failed to encrypt message: %v", err) + } + + if encrypted == "" { + t.Fatal("Encrypted message is empty") + } + + // Decrypt + decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid) + if err != nil { + t.Fatalf("Failed to decrypt message: %v", err) + } + + if decrypted != plaintext { + t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted) + } +} + +func TestGenerateSignature(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + timestamp := "1234567890" + nonce := "test_nonce" + encrypt := "encrypted_msg" + + signature := ch.generateSignature(timestamp, nonce, encrypt) + + if signature == "" { + t.Error("Generated signature is empty") + } + + // Verify signature using verifySignature function + if !verifySignature(cfg.Token, signature, timestamp, nonce, encrypt) { + t.Error("Generated signature does not verify correctly") + } +} diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go index 3ef1ecdf3..bc5a70fa3 100644 --- a/pkg/channels/wecom/init.go +++ b/pkg/channels/wecom/init.go @@ -13,4 +13,7 @@ func init() { channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewWeComAppChannel(cfg.Channels.WeComApp, b) }) + channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b) + }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9f4769de4..66f3945ed 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -192,19 +192,20 @@ func (d *AgentDefaults) GetModelName() string { } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram TelegramConfig `json:"telegram"` - Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` - Slack SlackConfig `json:"slack"` - LINE LINEConfig `json:"line"` - OneBot OneBotConfig `json:"onebot"` - WeCom WeComConfig `json:"wecom"` - WeComApp WeComAppConfig `json:"wecom_app"` - Pico PicoConfig `json:"pico"` + WhatsApp WhatsAppConfig `json:"whatsapp"` + Telegram TelegramConfig `json:"telegram"` + Feishu FeishuConfig `json:"feishu"` + Discord DiscordConfig `json:"discord"` + MaixCam MaixCamConfig `json:"maixcam"` + QQ QQConfig `json:"qq"` + DingTalk DingTalkConfig `json:"dingtalk"` + Slack SlackConfig `json:"slack"` + LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` + WeCom WeComConfig `json:"wecom"` + WeComApp WeComAppConfig `json:"wecom_app"` + WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` + Pico PicoConfig `json:"pico"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -360,6 +361,19 @@ type WeComAppConfig struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` } +type WeComAIBotConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` + MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps + WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome +} + type PicoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` @@ -637,7 +651,8 @@ func (c *Config) migrateChannelConfigs() { } // OneBot: group_trigger_prefix -> group_trigger.prefixes - if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { + if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && + len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix } } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 44f4de7e9..fce955c83 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -137,6 +137,18 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, }, + WeComAIBot: WeComAIBotConfig{ + Enabled: false, + Token: "", + EncodingAESKey: "", + WebhookHost: "0.0.0.0", + WebhookPort: 18791, + WebhookPath: "/webhook/wecom-aibot", + AllowFrom: FlexibleStringSlice{}, + ReplyTimeout: 5, + MaxSteps: 10, + WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", + }, Pico: PicoConfig{ Enabled: false, Token: "", From c7d4012fc9ab678168a7418d962fa7b8b9163ceb Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 14:04:26 +0800 Subject: [PATCH 073/132] fix(wecom-aibot): correct variable name in JSON parsing in message callback handler --- pkg/channels/wecom/aibot.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index c2f98806b..115fde9f7 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -351,9 +351,9 @@ func (c *WeComAIBotChannel) handleMessageCallback( var encryptedMsg struct { Encrypt string `json:"encrypt"` } - if err := json.Unmarshal(body, &encryptedMsg); err != nil { + if unmarshalErr := json.Unmarshal(body, &encryptedMsg); unmarshalErr != nil { logger.ErrorCF("wecom_aibot", "Failed to parse JSON body", map[string]any{ - "error": err, + "error": unmarshalErr, "body": string(body), }) http.Error(w, "Failed to parse JSON", http.StatusBadRequest) From a25726e7981ba8b184b401c9f198b5525fb06a41 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 14:38:41 +0800 Subject: [PATCH 074/132] feat(wecom): add WeCom AI Bot integration and update documentation --- README.fr.md | 46 ++++++- README.ja.md | 46 ++++++- README.md | 46 ++++++- README.pt-br.md | 46 ++++++- README.vi.md | 46 ++++++- README.zh.md | 2 +- config/config.example.json | 2 +- docs/channels/wecom/wecom_aibot/README.zh.md | 120 +++++++++++++++++++ docs/wecom-app-configuration.md | 115 ------------------ 9 files changed, 327 insertions(+), 142 deletions(-) create mode 100644 docs/channels/wecom/wecom_aibot/README.zh.md delete mode 100644 docs/wecom-app-configuration.md diff --git a/README.fr.md b/README.fr.md index 2bec768fc..43a6cab7a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -288,7 +288,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom | **QQ** | Facile (AppID + AppSecret) | | **DingTalk** | Moyen (identifiants de l'application) | | **LINE** | Moyen (identifiants + URL de webhook) | -| **WeCom** | Moyen (CorpID + configuration webhook) | +| **WeCom AI Bot** | Moyen (Token + clé AES) |
Telegram (Recommandé) @@ -491,12 +491,13 @@ picoclaw gateway
WeCom (WeChat Work) -PicoClaw prend en charge deux types d'intégration WeCom : +PicoClaw prend en charge trois types d'intégration WeCom : -**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe -**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive +**Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe +**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement +**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé -Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées. +Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées. **Configuration Rapide - WeCom Bot :** @@ -563,6 +564,41 @@ picoclaw gateway > **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production. +**Configuration Rapide - WeCom AI Bot :** + +**1. Créer un AI Bot** + +* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot +* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot` +* Copiez le **Token** et générez l'**EncodingAESKey** + +**2. Configurer** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Bonjour ! Comment puis-je vous aider ?" + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`. +
## ClawdChat Rejoignez le Réseau Social d'Agents diff --git a/README.ja.md b/README.ja.md index 15ed1f649..a6af79eb6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -257,7 +257,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき | **QQ** | 簡単(AppID + AppSecret) | | **DingTalk** | 普通(アプリ認証情報) | | **LINE** | 普通(認証情報 + Webhook URL) | -| **WeCom** | 普通(CorpID + Webhook設定) | +| **WeCom AI Bot** | 普通(Token + AES キー) |
Telegram(推奨) @@ -456,12 +456,13 @@ picoclaw gateway
WeCom (企業微信) -PicoClaw は2種類の WeCom 統合をサポートしています: +PicoClaw は3種類の WeCom 統合をサポートしています: -**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応 -**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応 +**オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応 +**オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ +**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応 -詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。 +詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。 **クイックセットアップ - WeCom Bot:** @@ -530,6 +531,41 @@ picoclaw gateway > **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。 +**クイックセットアップ - WeCom AI Bot:** + +**1. AI Bot を作成** + +* WeCom 管理コンソール → アプリ管理 → AI Bot +* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot` +* **Token** をコピーし、**EncodingAESKey** を生成 + +**2. 設定** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "こんにちは!何かお手伝いできますか?" + } + } +} +``` + +**3. 起動** + +```bash +picoclaw gateway +``` + +> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>5.5分)は自動的に `response_url` によるプッシュ配信に切り替わります。 +
## ⚙️ 設定 diff --git a/README.md b/README.md index 2fc60343b..e49acb362 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | -| **WeCom** | Medium (CorpID + webhook setup) | +| **WeCom AI Bot** | Medium (Token + AES key) |
Telegram (Recommended) @@ -557,12 +557,13 @@ picoclaw gateway
WeCom (企业微信) -PicoClaw supports two types of WeCom integration: +PicoClaw supports three types of WeCom integration: -**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats -**Option 2: WeCom App (自建应用)** - More features, proactive messaging +**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats +**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only +**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat -See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions. +See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions. **Quick Setup - WeCom Bot:** @@ -631,6 +632,41 @@ picoclaw gateway > **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS. +**Quick Setup - WeCom AI Bot:** + +**1. Create an AI Bot** + +* Go to WeCom Admin Console → App Management → AI Bot +* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` +* Copy **Token** and click "Random Generate" for **EncodingAESKey** + +**2. Configure** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>5.5 min) automatically switch to `response_url` push delivery. +
## ClawdChat Join the Agent Social Network diff --git a/README.pt-br.md b/README.pt-br.md index 611a61281..c37fb929b 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -282,7 +282,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom. | **QQ** | Fácil (AppID + AppSecret) | | **DingTalk** | Médio (credenciais do app) | | **LINE** | Médio (credenciais + webhook URL) | -| **WeCom** | Médio (CorpID + configuração webhook) | +| **WeCom AI Bot** | Médio (Token + chave AES) |
Telegram (Recomendado) @@ -485,12 +485,13 @@ picoclaw gateway
WeCom (WeChat Work) -O PicoClaw suporta dois tipos de integração WeCom: +O PicoClaw suporta três tipos de integração WeCom: -**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo -**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas +**Opção 1: WeCom Bot (Robô)** - Configuração mais fácil, suporta chats em grupo +**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas, somente chat privado +**Opção 3: WeCom AI Bot (Robô Inteligente)** - Bot IA oficial, respostas em streaming, suporta grupo e privado -Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas. +Veja o [Guia de Configuração WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas. **Configuração Rápida - WeCom Bot:** @@ -559,6 +560,41 @@ picoclaw gateway > **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário. +**Configuração Rápida - WeCom AI Bot:** + +**1. Criar um AI Bot** + +* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot +* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot` +* Copie o **Token** e gere o **EncodingAESKey** + +**2. Configurar** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Olá! Como posso ajudá-lo?" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`. +
## ClawdChat Junte-se a Rede Social de Agentes diff --git a/README.vi.md b/README.vi.md index e836e30f0..417ca0393 100644 --- a/README.vi.md +++ b/README.vi.md @@ -256,7 +256,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom. | **QQ** | Dễ (AppID + AppSecret) | | **DingTalk** | Trung bình (app credentials) | | **LINE** | Trung bình (credentials + webhook URL) | -| **WeCom** | Trung bình (CorpID + cấu hình webhook) | +| **WeCom AI Bot** | Trung bình (Token + khóa AES) |
Telegram (Khuyên dùng) @@ -457,12 +457,13 @@ picoclaw gateway
WeCom (WeChat Work) -PicoClaw hỗ trợ hai loại tích hợp WeCom: +PicoClaw hỗ trợ ba loại tích hợp WeCom: -**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm -**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động +**Tùy chọn 1: WeCom Bot (Robot)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm +**Tùy chọn 2: WeCom App (Ứng dụng Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng tư +**Tùy chọn 3: WeCom AI Bot (Bot Thông Minh)** - Bot AI chính thức, phản hồi streaming, hỗ trợ nhóm và riêng tư -Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiết. +Xem [Hướng dẫn Cấu hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn chi tiết. **Thiết lập Nhanh - WeCom Bot:** @@ -531,6 +532,41 @@ picoclaw gateway > **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần. +**Thiết lập Nhanh - WeCom AI Bot:** + +**1. Tạo AI Bot** + +* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot +* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot` +* Sao chép **Token** và tạo **EncodingAESKey** + +**2. Cấu hình** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?" + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`. +
## ClawdChat Tham gia Mạng xã hội Agent diff --git a/README.zh.md b/README.zh.md index 95984bbdf..d3a49ee8d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -301,7 +301,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | | **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | -| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) | +| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) | | **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) | | **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | diff --git a/config/config.example.json b/config/config.example.json index df72a876e..36783d0ea 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -138,7 +138,7 @@ "reasoning_channel_id": "" }, "wecom_app": { - "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md", + "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.", "enabled": false, "corp_id": "YOUR_CORP_ID", "corp_secret": "YOUR_CORP_SECRET", diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md new file mode 100644 index 000000000..200a83a69 --- /dev/null +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -0,0 +1,120 @@ +# 企业微信智能机器人 (AI Bot) + +企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。 + +## 与其他 WeCom 通道的对比 + +| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** | +|------|-----------|-----------|-----------------| +| 私聊 | ✅ | ✅ | ✅ | +| 群聊 | ✅ | ❌ | ✅ | +| 流式输出 | ❌ | ❌ | ✅ | +| 超时主动推送 | ❌ | ✅ | ✅ | +| 配置复杂度 | 低 | 高 | 中 | + +## 配置 + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------------- | ------ | ---- | -------------------------------------------------- | +| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | +| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | +| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) | +| webhook_port | int | 否 | HTTP 服务器端口(默认:18791) | +| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) | +| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | +| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | +| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | +| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | + +## 设置流程 + +1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) +2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot +3. 在 AI Bot 配置页面,填写"消息接收"信息: + - **URL**:`http://:18791/webhook/wecom-aibot` + - **Token**:随机生成或自定义 + - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥 +4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求) + +> [!TIP] +> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。 + +## 流式响应协议 + +WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复: + +``` +用户发消息 + │ + ▼ +PicoClaw 立即返回 {finish: false}(Agent 开始处理) + │ + ▼ +企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}} + │ + ├─ Agent 未完成 → 返回 {finish: false}(继续等待) + │ + └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} +``` + +**超时处理**(任务超过 5 分 30 秒): + +若 Agent 处理时间超过约 5 分 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: + +1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」 +2. Agent 继续在后台运行 +3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户 + +> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。 + +## 欢迎语 + +配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。 + +```json +"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" +``` + +## 常见问题 + +### 回调 URL 验证失败 + +- 确认服务器防火墙已开放对应端口(默认 18791) +- 确认 `token` 与 `encoding_aes_key` 填写正确 +- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求 + +### 消息没有回复 + +- 检查 `allow_from` 是否意外限制了发送者 +- 查看日志中是否出现 `context canceled` 或 Agent 错误 +- 确认 Agent 配置(`model_name` 等)正确 + +### 超长任务没有收到最终推送 + +- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持) +- 确认服务器能主动访问外网(需向 `response_url` POST 请求) +- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url` + +## 参考文档 + +- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719) +- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719) +- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/wecom-app-configuration.md b/docs/wecom-app-configuration.md deleted file mode 100644 index 3c720ecd1..000000000 --- a/docs/wecom-app-configuration.md +++ /dev/null @@ -1,115 +0,0 @@ -# 企业微信自建应用 (WeCom App) 配置指南 - -本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。 - -## 功能特性 - -| 功能 | 支持状态 | -|------|---------| -| 被动接收消息 | ✅ | -| 主动发送消息 | ✅ | -| 私聊 | ✅ | -| 群聊 | ❌ | - -## 配置步骤 - -### 1. 企业微信后台配置 - -1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) -2. 进入"应用管理" → 选择自建应用 -3. 记录以下信息: - - **AgentId**: 应用详情页显示 - - **Secret**: 点击"查看"获取 -4. 进入"我的企业"页面,记录 **企业ID** (CorpID) - -### 2. 接收消息配置 - -1. 在应用详情页,点击"接收消息"的"设置API接收" -2. 填写以下信息: - - **URL**: `http://your-server:18790/webhook/wecom-app` - - **Token**: 随机生成或自定义(用于签名验证) - - **EncodingAESKey**: 点击"随机生成"生成43字符的密钥 -3. 点击"保存"时,企业微信会发送验证请求 - -### 3. PicoClaw 配置 - -在 `config.json` 中添加以下配置: - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID - "corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret - "agent_id": 1000002, // 应用AgentId - "token": "your_token", // 接收消息配置的Token - "encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey - "webhook_path": "/webhook/wecom-app", - "allow_from": [], - "reply_timeout": 5 - } - } -} -``` - -## 常见问题 - -### 1. 回调URL验证失败 - -**症状**: 企业微信保存API接收消息时提示验证失败 - -**检查项**: -- 确认服务器防火墙已开放 Gateway 端口(默认 18790) -- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确 -- 查看 PicoClaw 日志是否有请求到达 - -### 2. 中文消息解密失败 - -**症状**: 发送中文消息时出现 `invalid padding size` 错误 - -**原因**: 企业微信使用非标准的 PKCS7 填充(32字节块大小) - -**解决**: 确保使用最新版本的 PicoClaw,已修复此问题。 - -### 3. 端口冲突 - -**症状**: 启动时提示端口已被占用 - -**解决**: 修改 `gateway.port` 为其他端口(所有 Webhook 渠道共享同一个 Gateway HTTP 服务器) - -## 技术细节 - -### 加密算法 - -- **算法**: AES-256-CBC -- **密钥**: EncodingAESKey Base64解码后的32字节 -- **IV**: AESKey的前16字节 -- **填充**: PKCS7(块大小为32字节,非标准16字节) -- **消息格式**: XML - -### 消息结构 - -解密后的消息格式: -``` -random(16B) + msg_len(4B) + msg + receiveid -``` - -其中 `receiveid` 对于自建应用是 `corp_id`。 - -## 调试 - -启用调试模式查看详细日志: - -```bash -picoclaw gateway --debug -``` - -关键日志标识: -- `wecom_app`: WeCom App 通道相关日志 -- `wecom_common`: 加密解密相关日志 - -## 参考文档 - -- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211) -- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang) From e894f8d39af59724e9d74b017b11ec417a6ef0f2 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:08:07 +0800 Subject: [PATCH 075/132] feat(wecom-aibot): add reasoning_channel_id to configuration and enhance message handling limits --- config/config.example.json | 3 ++- pkg/channels/wecom/aibot.go | 17 ++++++++++++++--- pkg/config/config.go | 21 +++++++++++---------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 36783d0ea..872358bd4 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -159,7 +159,8 @@ "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "max_steps": 10, - "welcome_message": "Hello! I'm your AI assistant. How can I help you today?" + "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", + "reasoning_channel_id": "" } }, "providers": { diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 115fde9f7..c4970d8fb 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -130,6 +130,7 @@ func NewWeComAIBotChannel( base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &WeComAIBotChannel{ @@ -336,8 +337,9 @@ func (c *WeComAIBotChannel) handleMessageCallback( timestamp := r.URL.Query().Get("timestamp") nonce := r.URL.Query().Get("nonce") - // Read request body - body, err := io.ReadAll(r.Body) + // Read request body (limit to 4 MB to prevent memory exhaustion) + const maxBodySize = 4 << 20 // 4 MB + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) if err != nil { logger.ErrorCF("wecom_aibot", "Failed to read request body", map[string]any{ "error": err, @@ -345,6 +347,10 @@ func (c *WeComAIBotChannel) handleMessageCallback( http.Error(w, "Failed to read body", http.StatusBadRequest) return } + if len(body) > maxBodySize { + http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) + return + } // Parse JSON body to get encrypted message // Format: {"encrypt": "base64_encrypted_string"} @@ -1024,10 +1030,15 @@ func (c *WeComAIBotChannel) downloadAndDecryptImage( return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) } - encryptedData, err := io.ReadAll(resp.Body) + // Limit image download to 20 MB to prevent memory exhaustion + const maxImageSize = 20 << 20 // 20 MB + encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1)) if err != nil { return nil, fmt.Errorf("failed to read image data: %w", err) } + if len(encryptedData) > maxImageSize { + return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20) + } logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ "size": len(encryptedData), diff --git a/pkg/config/config.go b/pkg/config/config.go index 66f3945ed..439c2b995 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -362,16 +362,17 @@ type WeComAppConfig struct { } type WeComAIBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` - MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps - WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` + MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps + WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` } type PicoConfig struct { From e33712deff3be0b766d5d4490b646f22685aff06 Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:26:26 +0800 Subject: [PATCH 076/132] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index c4970d8fb..7b0470b40 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -44,7 +44,7 @@ type streamTask struct { ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) Question string CreatedTime time.Time - Deadline time.Time // ~5m30s, we close the stream here and switch to response_url + Deadline time.Time // ~30s, we close the stream here and switch to response_url StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url Finished bool // fully done mu sync.Mutex From aa9ce6955b18574cc920907703fa22654c1078d4 Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:29:51 +0800 Subject: [PATCH 077/132] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 7b0470b40..d26ed9066 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -93,21 +93,24 @@ type WeComAIBotMessage struct { } `json:"event,omitempty"` } +// WeComAIBotStreamInfo represents the detailed stream content in streaming responses +type WeComAIBotStreamInfo struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` +} + // WeComAIBotStreamResponse represents the streaming response format type WeComAIBotStreamResponse struct { - MsgType string `json:"msgtype"` - Stream struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - } `json:"stream"` + MsgType string `json:"msgtype"` + Stream WeComAIBotStreamInfo `json:"stream"` } // WeComAIBotEncryptedResponse represents the encrypted response wrapper From 0b6d913dfca4b72f56703799c14809ef4cdc629f Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:31:39 +0800 Subject: [PATCH 078/132] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index d26ed9066..7924f38e6 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -181,6 +181,9 @@ func (c *WeComAIBotChannel) Stop(ctx context.Context) error { // It writes into the earliest unfinished task in the queue (FIFO per chatID). // If the stream has already closed (deadline passed), it posts directly to response_url. func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } c.taskMu.Lock() queue := c.chatTasks[msg.ChatID] for len(queue) > 0 && queue[0].Finished { From 4e09c91dda6ae1f2239b94b07d6e54b7e2b179cd Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:38:49 +0800 Subject: [PATCH 079/132] feat(wecom-aibot): add context management for stream tasks to improve agent cancellation --- pkg/channels/wecom/aibot.go | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 7924f38e6..b54202ece 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -48,7 +48,9 @@ type streamTask struct { StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url Finished bool // fully done mu sync.Mutex - answerCh chan string // receives agent reply from Send() + answerCh chan string // receives agent reply from Send() + ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine + cancel context.CancelFunc // call on task removal to cancel ctx } // WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot @@ -109,7 +111,7 @@ type WeComAIBotStreamInfo struct { // WeComAIBotStreamResponse represents the streaming response format type WeComAIBotStreamResponse struct { - MsgType string `json:"msgtype"` + MsgType string `json:"msgtype"` Stream WeComAIBotStreamInfo `json:"stream"` } @@ -237,6 +239,9 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e // Stream still open: deliver via answerCh for the next poll response. select { case task.answerCh <- msg.Content: + case <-task.ctx.Done(): + // Task was canceled (cleanup removed it); silently drop the reply. + return nil case <-ctx.Done(): return ctx.Err() } @@ -490,6 +495,10 @@ func (c *WeComAIBotChannel) handleTextMessage( // Set a slightly shorter deadline so we can send a timeout notice before it gives up. deadline := time.Now().Add(30 * time.Second) + // Each task gets its own context derived from the channel lifetime context. + // Canceling taskCancel interrupts the agent goroutine when the task is removed. + taskCtx, taskCancel := context.WithCancel(c.ctx) + task := &streamTask{ StreamID: streamID, ChatID: chatID, @@ -499,6 +508,8 @@ func (c *WeComAIBotChannel) handleTextMessage( Deadline: deadline, Finished: false, answerCh: make(chan string, 1), + ctx: taskCtx, + cancel: taskCancel, } c.taskMu.Lock() @@ -506,8 +517,8 @@ func (c *WeComAIBotChannel) handleTextMessage( c.chatTasks[chatID] = append(c.chatTasks[chatID], task) c.taskMu.Unlock() - // Publish to agent asynchronously; agent will call Send() with reply - // Use c.ctx (channel lifetime) instead of r.Context() which is canceled when the HTTP handler returns. + // Publish to agent asynchronously; agent will call Send() with reply. + // Use task.ctx (not c.ctx) so the agent goroutine is canceled when the task is removed. go func() { sender := bus.SenderInfo{ Platform: "wecom_aibot", @@ -529,7 +540,7 @@ func (c *WeComAIBotChannel) handleTextMessage( "stream_id": streamID, "response_url": msg.ResponseURL, } - c.HandleMessage(c.ctx, peer, msg.MsgID, userID, chatID, + c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID, content, nil, metadata, sender) }() @@ -800,11 +811,13 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce return c.encryptResponse(task.StreamID, timestamp, nonce, response) } -// removeTask removes a task from both streamTasks and chatTasks and marks it finished. +// removeTask removes a task from both streamTasks and chatTasks, marks it finished, +// and cancels its context to interrupt the associated agent goroutine. func (c *WeComAIBotChannel) removeTask(task *streamTask) { task.mu.Lock() task.Finished = true task.mu.Unlock() + task.cancel() // interrupt agent goroutine bound to this task c.taskMu.Lock() delete(c.streamTasks, task.StreamID) @@ -1114,6 +1127,7 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { for id, task := range c.streamTasks { if task.CreatedTime.Before(cutoff) { delete(c.streamTasks, id) + task.cancel() // interrupt agent goroutine still waiting for LLM queue := c.chatTasks[task.ChatID] for i, t := range queue { if t == task { @@ -1130,11 +1144,14 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { } } // Also clean up StreamClosed tasks from chatTasks that are older than 1 hour. + // These were removed from streamTasks earlier but kept alive for response_url delivery. for chatID, queue := range c.chatTasks { filtered := queue[:0] for _, t := range queue { if !t.Finished && t.CreatedTime.After(cutoff) { filtered = append(filtered, t) + } else if !t.Finished { + t.cancel() // cancel any lingering agent goroutine } } if len(filtered) == 0 { From a87e6b0551593a466c3b3a863b5f4a8ebf7959ba Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:45:32 +0800 Subject: [PATCH 080/132] feat(wecom-aibot): enhance stream task management with StreamClosedAt and improved cleanup logic --- pkg/channels/wecom/aibot.go | 56 +++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index b54202ece..4be430626 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -39,18 +39,19 @@ type WeComAIBotChannel struct { // streamTask represents a streaming task for AI Bot type streamTask struct { - StreamID string - ChatID string // used by Send() to find this task - ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) - Question string - CreatedTime time.Time - Deadline time.Time // ~30s, we close the stream here and switch to response_url - StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url - Finished bool // fully done - mu sync.Mutex - answerCh chan string // receives agent reply from Send() - ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine - cancel context.CancelFunc // call on task removal to cancel ctx + StreamID string + ChatID string // used by Send() to find this task + ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) + Question string + CreatedTime time.Time + Deadline time.Time // ~30s, we close the stream here and switch to response_url + StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url + StreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup + Finished bool // fully done + mu sync.Mutex + answerCh chan string // receives agent reply from Send() + ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine + cancel context.CancelFunc // call on task removal to cancel ctx } // WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot @@ -781,6 +782,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce // Only mark stream as closed; keep in chatTasks for Send() to find. task.mu.Lock() task.StreamClosed = true + task.StreamClosedAt = time.Now() task.mu.Unlock() // Remove from streamTasks (no more stream polls expected). c.taskMu.Lock() @@ -1117,13 +1119,24 @@ func (c *WeComAIBotChannel) cleanupLoop() { } } -// cleanupOldTasks removes tasks that have been alive longer than 1 hour -// (response_url validity window), which is the absolute maximum lifetime of any task. +// cleanupOldTasks removes tasks that have exceeded their expected lifetime: +// - Active tasks (in streamTasks): cleaned up after 1 hour (response_url validity window). +// - StreamClosed tasks (in chatTasks only): cleaned up after streamClosedGracePeriod. +// These tasks are waiting for the agent to call Send() via response_url. If the agent +// crashes or times out without calling Send(), we must not let them accumulate indefinitely. +// The grace period is generous enough to cover typical LLM latency but far shorter than 1 hour, +// preventing chatTasks from filling up when many requests time out in quick succession. +const ( + streamClosedGracePeriod = 10 * time.Minute // max wait for agent after stream closes + taskMaxLifetime = 1 * time.Hour // absolute max (≈ response_url validity) +) + func (c *WeComAIBotChannel) cleanupOldTasks() { c.taskMu.Lock() defer c.taskMu.Unlock() - cutoff := time.Now().Add(-1 * time.Hour) + now := time.Now() + cutoff := now.Add(-taskMaxLifetime) for id, task := range c.streamTasks { if task.CreatedTime.Before(cutoff) { delete(c.streamTasks, id) @@ -1143,12 +1156,19 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { }) } } - // Also clean up StreamClosed tasks from chatTasks that are older than 1 hour. - // These were removed from streamTasks earlier but kept alive for response_url delivery. + // Clean up StreamClosed tasks from chatTasks. + // Two expiry conditions are checked: + // 1. Absolute expiry: task was created more than taskMaxLifetime ago. + // 2. Grace expiry: stream closed more than streamClosedGracePeriod ago + // (agent had enough time to reply; it is not coming back). for chatID, queue := range c.chatTasks { filtered := queue[:0] for _, t := range queue { - if !t.Finished && t.CreatedTime.After(cutoff) { + absoluteExpired := t.CreatedTime.Before(cutoff) + graceExpired := t.StreamClosed && + !t.StreamClosedAt.IsZero() && + t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod)) + if !t.Finished && !absoluteExpired && !graceExpired { filtered = append(filtered, t) } else if !t.Finished { t.cancel() // cancel any lingering agent goroutine From 4a87090fd9d95ebfe3597fb895fe3565f37c9871 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:50:32 +0800 Subject: [PATCH 081/132] fix(docs): update WeCom AI Bot task timeout duration in README --- README.ja.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index a6af79eb6..34c034243 100644 --- a/README.ja.md +++ b/README.ja.md @@ -564,7 +564,7 @@ picoclaw gateway picoclaw gateway ``` -> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>5.5分)は自動的に `response_url` によるプッシュ配信に切り替わります。 +> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒)は自動的に `response_url` によるプッシュ配信に切り替わります。
diff --git a/README.md b/README.md index e49acb362..046213598 100644 --- a/README.md +++ b/README.md @@ -665,7 +665,7 @@ picoclaw gateway picoclaw gateway ``` -> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>5.5 min) automatically switch to `response_url` push delivery. +> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
From e88b39f21e6bb619b43081591be75a931897efe4 Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:54:00 +0800 Subject: [PATCH 082/132] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 4be430626..de1a50c4a 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -928,9 +928,12 @@ func (c *WeComAIBotChannel) encryptResponse( return string(respJSON) } -// encryptEmptyResponse returns empty encrypted response +// encryptEmptyResponse returns a minimal valid encrypted response func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string { - return "" + // Construct a zero-value stream response and encrypt it so that + // WeCom always receives a syntactically valid encrypted JSON object. + emptyResp := WeComAIBotStreamResponse{} + return c.encryptResponse("", timestamp, nonce, emptyResp) } // encryptMessage encrypts a plain text message for WeCom AI Bot From 81f6787dd59eedcc4061045b222880276df60afc Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 16:05:26 +0800 Subject: [PATCH 083/132] fix(docs): update WeCom AI Bot timeout duration in README and improve streamTask comments --- docs/channels/wecom/wecom_aibot/README.zh.md | 4 +- pkg/channels/wecom/aibot.go | 56 +++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md index 200a83a69..8470fe16f 100644 --- a/docs/channels/wecom/wecom_aibot/README.zh.md +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -75,9 +75,9 @@ PicoClaw 立即返回 {finish: false}(Agent 开始处理) └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} ``` -**超时处理**(任务超过 5 分 30 秒): +**超时处理**(任务超过 30 秒): -若 Agent 处理时间超过约 5 分 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: +若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: 1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」 2. Agent 继续在后台运行 diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index de1a50c4a..2962623e1 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -37,21 +37,28 @@ type WeComAIBotChannel struct { taskMu sync.RWMutex } -// streamTask represents a streaming task for AI Bot +// streamTask represents a streaming task for AI Bot. +// +// Mutable fields (Finished, StreamClosed, StreamClosedAt) must be read/written +// while holding WeComAIBotChannel.taskMu. Immutable fields (StreamID, ChatID, +// ResponseURL, Question, CreatedTime, Deadline, answerCh, ctx, cancel) are set +// once at creation and never modified, so they are safe to read without a lock. type streamTask struct { - StreamID string - ChatID string // used by Send() to find this task - ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) - Question string - CreatedTime time.Time - Deadline time.Time // ~30s, we close the stream here and switch to response_url + // immutable after creation + StreamID string + ChatID string // used by Send() to find this task + ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) + Question string + CreatedTime time.Time + Deadline time.Time // ~30s, we close the stream here and switch to response_url + answerCh chan string // receives agent reply from Send() + ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine + cancel context.CancelFunc // call on task removal to cancel ctx + + // mutable — guarded by WeComAIBotChannel.taskMu StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url StreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup Finished bool // fully done - mu sync.Mutex - answerCh chan string // receives agent reply from Send() - ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine - cancel context.CancelFunc // call on task removal to cancel ctx } // WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot @@ -194,8 +201,13 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e } c.chatTasks[msg.ChatID] = queue var task *streamTask + var streamClosed bool + var responseURL string if len(queue) > 0 { task = queue[0] + // Read mutable fields while holding c.taskMu to avoid data races. + streamClosed = task.StreamClosed + responseURL = task.ResponseURL } c.taskMu.Unlock() @@ -210,13 +222,9 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e return nil } - task.mu.Lock() - streamClosed := task.StreamClosed - responseURL := task.ResponseURL - task.mu.Unlock() - if streamClosed { // Stream already ended with a "please wait" notice; send the real reply via response_url. + // Note: task.StreamID and task.ChatID are immutable, safe to read without a lock. logger.InfoCF("wecom_aibot", "Sending reply via response_url", map[string]any{ "stream_id": task.StreamID, "chat_id": msg.ChatID, @@ -779,13 +787,11 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce // Normal finish: remove from all maps. c.removeTask(task) } else if closeStreamOnly { - // Only mark stream as closed; keep in chatTasks for Send() to find. - task.mu.Lock() + // Mark stream as closed and remove from streamTasks under a single lock + // to keep StreamClosed/StreamClosedAt consistent with map membership. + c.taskMu.Lock() task.StreamClosed = true task.StreamClosedAt = time.Now() - task.mu.Unlock() - // Remove from streamTasks (no more stream polls expected). - c.taskMu.Lock() delete(c.streamTasks, task.StreamID) c.taskMu.Unlock() } @@ -816,12 +822,12 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce // removeTask removes a task from both streamTasks and chatTasks, marks it finished, // and cancels its context to interrupt the associated agent goroutine. func (c *WeComAIBotChannel) removeTask(task *streamTask) { - task.mu.Lock() - task.Finished = true - task.mu.Unlock() - task.cancel() // interrupt agent goroutine bound to this task + // Cancel first so the agent goroutine stops as soon as possible, + // before we acquire the write lock. + task.cancel() c.taskMu.Lock() + task.Finished = true // written under c.taskMu, consistent with all readers delete(c.streamTasks, task.StreamID) queue := c.chatTasks[task.ChatID] for i, t := range queue { From 8f3d611a4c56f43be7d76ff65e8d7b82d8fe5e98 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 22:56:55 +0800 Subject: [PATCH 084/132] refactor(wecom): replace generateSignature with computeSignature and update related tests --- pkg/channels/wecom/aibot.go | 25 +++++-------------------- pkg/channels/wecom/aibot_test.go | 14 +++----------- pkg/channels/wecom/common.go | 24 +++++++++++------------- 3 files changed, 19 insertions(+), 44 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 2962623e1..788305e36 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -6,7 +6,6 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" - "crypto/sha1" "encoding/base64" "encoding/binary" "encoding/json" @@ -14,7 +13,6 @@ import ( "io" "math/big" "net/http" - "sort" "strings" "sync" "time" @@ -291,13 +289,14 @@ func (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request "query": r.URL.RawQuery, }) - if r.Method == http.MethodGet { + switch r.Method { + case http.MethodGet: // URL verification c.handleVerification(ctx, w, r) - } else if r.Method == http.MethodPost { + case http.MethodPost: // Message callback c.handleMessageCallback(ctx, w, r) - } else { + default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } @@ -909,7 +908,7 @@ func (c *WeComAIBotChannel) encryptResponse( } // Generate signature - signature := c.generateSignature(timestamp, nonce, encrypted) + signature := computeSignature(c.config.Token, timestamp, nonce, encrypted) // Build encrypted response encryptedResp := WeComAIBotEncryptedResponse{ @@ -1010,20 +1009,6 @@ func pkcs7Pad(data []byte, blockSize int) []byte { return append(data, padText...) } -// generateSignature generates message signature using common function -func (c *WeComAIBotChannel) generateSignature(timestamp, nonce, encrypt string) string { - // Sort parameters - params := []string{c.config.Token, timestamp, nonce, encrypt} - sort.Strings(params) - - // Concatenate - str := strings.Join(params, "") - - // SHA1 hash - hash := sha1.Sum([]byte(str)) - return fmt.Sprintf("%x", hash) -} - // generateStreamID generates a random stream ID func (c *WeComAIBotChannel) generateStreamID() string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go index 7fb90f22e..6f0664187 100644 --- a/pkg/channels/wecom/aibot_test.go +++ b/pkg/channels/wecom/aibot_test.go @@ -192,27 +192,19 @@ func TestEncryptDecrypt(t *testing.T) { } func TestGenerateSignature(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } - - messageBus := bus.NewMessageBus() - ch, _ := NewWeComAIBotChannel(cfg, messageBus) - + token := "test_token" timestamp := "1234567890" nonce := "test_nonce" encrypt := "encrypted_msg" - signature := ch.generateSignature(timestamp, nonce, encrypt) + signature := computeSignature(token, timestamp, nonce, encrypt) if signature == "" { t.Error("Generated signature is empty") } // Verify signature using verifySignature function - if !verifySignature(cfg.Token, signature, timestamp, nonce, encrypt) { + if !verifySignature(token, signature, timestamp, nonce, encrypt) { t.Error("Generated signature does not verify correctly") } } diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go index 39a27d04c..b1b5399f4 100644 --- a/pkg/channels/wecom/common.go +++ b/pkg/channels/wecom/common.go @@ -14,25 +14,23 @@ import ( // blockSize is the PKCS7 block size used by WeCom (32) const blockSize = 32 +// computeSignature computes the WeCom message signature from the given parameters. +// It sorts [token, timestamp, nonce, encrypt], concatenates them and returns the SHA1 hex digest. +func computeSignature(token, timestamp, nonce, encrypt string) string { + params := []string{token, timestamp, nonce, encrypt} + sort.Strings(params) + str := strings.Join(params, "") + hash := sha1.Sum([]byte(str)) + return fmt.Sprintf("%x", hash) +} + // verifySignature verifies the message signature for WeCom // This is a common function used by both WeCom Bot and WeCom App func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { if token == "" { return true // Skip verification if token is not set } - - // Sort parameters - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - - // Concatenate - str := strings.Join(params, "") - - // SHA1 hash - hash := sha1.Sum([]byte(str)) - expectedSignature := fmt.Sprintf("%x", hash) - - return expectedSignature == msgSignature + return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature } // decryptMessage decrypts the encrypted message using AES From 880c402ab7025fd2a65bda487486c854c52647f5 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:14:10 +0800 Subject: [PATCH 085/132] refactor(wecom): streamline AES encryption/decryption and improve task management logic --- pkg/channels/wecom/aibot.go | 166 +++++++++-------------------------- pkg/channels/wecom/common.go | 135 +++++++++++++++++++++------- 2 files changed, 144 insertions(+), 157 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 788305e36..9003b0777 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -3,11 +3,8 @@ package wecom import ( "bytes" "context" - "crypto/aes" - "crypto/cipher" "crypto/rand" "encoding/base64" - "encoding/binary" "encoding/json" "fmt" "io" @@ -194,6 +191,12 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e } c.taskMu.Lock() queue := c.chatTasks[msg.ChatID] + // Only compact Finished tasks at the head of the queue. + // Tasks that are Finished in the middle are NOT removed here: doing a full + // scan on every Send() call would be O(n) and is unnecessary given that + // removeTask() always splices the task out of the queue immediately. + // Any Finished task left stranded in the middle (e.g. due to an unexpected + // code path) will be collected by cleanupOldTasks. for len(queue) > 0 && queue[0].Finished { queue = queue[1:] } @@ -620,41 +623,6 @@ func (c *WeComAIBotChannel) handleImageMessage( imageURL := msg.Image.URL - // Download and decrypt image - _, err := c.downloadAndDecryptImage(ctx, imageURL) - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{ - "error": err, - "url": imageURL, - }) - return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ - ID: c.generateStreamID(), - Finish: true, - Content: fmt.Sprintf( - "Image received (URL: %s), but image messages are not yet supported", - imageURL, - ), - }, - }) - } - - // Echo back the image (simple demo behavior) - // streamID := c.generateStreamID() - // return c.encryptImageResponse(streamID, timestamp, nonce, imageData) - // For now, just acknowledge receipt without echoing the image return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", @@ -943,70 +911,24 @@ func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string // encryptMessage encrypts a plain text message for WeCom AI Bot func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) { - // Decode AES key - aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) if err != nil { - return "", fmt.Errorf("failed to decode AES key: %w", err) + return "", err } - if len(aesKey) != 32 { - return "", fmt.Errorf("invalid AES key length: %d", len(aesKey)) - } - - // Generate 16-byte random string - randomBytes := make([]byte, 16) - for i := range 16 { - n, randErr := rand.Int(rand.Reader, big.NewInt(10)) - if randErr != nil { - return "", fmt.Errorf("failed to generate random: %w", randErr) - } - randomBytes[i] = byte('0' + n.Int64()) - } - - // Build message: random(16) + msg_len(4) + msg + receiveid - plaintextBytes := []byte(plaintext) - receiveidBytes := []byte(receiveid) - - msgLen := uint32(len(plaintextBytes)) - msgLenBytes := make([]byte, 4) - binary.BigEndian.PutUint32(msgLenBytes, msgLen) - - // Concatenate - var buffer bytes.Buffer - buffer.Write(randomBytes) - buffer.Write(msgLenBytes) - buffer.Write(plaintextBytes) - buffer.Write(receiveidBytes) - - // PKCS7 padding - plainData := buffer.Bytes() - plainData = pkcs7Pad(plainData, blockSize) - - // AES-CBC encrypt - block, err := aes.NewCipher(aesKey) + frame, err := packWeComFrame(plaintext, receiveid) if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) + return "", err } - ciphertext := make([]byte, len(plainData)) - iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCEncrypter(block, iv) - mode.CryptBlocks(ciphertext, plainData) - - // Base64 encode - encoded := base64.StdEncoding.EncodeToString(ciphertext) - - return encoded, nil -} - -// pkcs7Pad adds PKCS7 padding -func pkcs7Pad(data []byte, blockSize int) []byte { - padding := blockSize - (len(data) % blockSize) - if padding == 0 { - padding = blockSize + // PKCS7 padding then AES-CBC encrypt + paddedFrame := pkcs7Pad(frame, blockSize) + ciphertext, err := encryptAESCBC(aesKey, paddedFrame) + if err != nil { + return "", err } - padText := bytes.Repeat([]byte{byte(padding)}, padding) - return append(data, padText...) + + return base64.StdEncoding.EncodeToString(ciphertext), nil } // generateStreamID generates a random stream ID @@ -1060,35 +982,15 @@ func (c *WeComAIBotChannel) downloadAndDecryptImage( }) // Decode AES key - aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) if err != nil { - return nil, fmt.Errorf("failed to decode AES key: %w", err) + return nil, err } - if len(aesKey) != 32 { - return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) - } - - // Decrypt image (AES-CBC) - block, err := aes.NewCipher(aesKey) + // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped) + decryptedData, err := decryptAESCBC(aesKey, encryptedData) if err != nil { - return nil, fmt.Errorf("failed to create cipher: %w", err) - } - - if len(encryptedData)%aes.BlockSize != 0 { - return nil, fmt.Errorf("encrypted data size not multiple of block size") - } - - iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCDecrypter(block, iv) - - decryptedData := make([]byte, len(encryptedData)) - mode.CryptBlocks(decryptedData, encryptedData) - - // Remove PKCS7 padding - decryptedData, err = pkcs7Unpad(decryptedData) - if err != nil { - return nil, fmt.Errorf("failed to unpad: %w", err) + return nil, fmt.Errorf("failed to decrypt image: %w", err) } logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ @@ -1157,14 +1059,32 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { // (agent had enough time to reply; it is not coming back). for chatID, queue := range c.chatTasks { filtered := queue[:0] - for _, t := range queue { + for i, t := range queue { absoluteExpired := t.CreatedTime.Before(cutoff) graceExpired := t.StreamClosed && !t.StreamClosedAt.IsZero() && t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod)) - if !t.Finished && !absoluteExpired && !graceExpired { + if t.Finished { + // Finished tasks should have been removed by removeTask(). + // Finding one here (especially not at position 0) means an + // unexpected code path left it stranded, causing the queue to + // grow silently. Log a warning so it is visible, then drop it. + if i > 0 { + logger.WarnCF("wecom_aibot", + "Found stranded Finished task in the middle of chatTasks queue; "+ + "this should not happen — removeTask() should have spliced it out", + map[string]any{ + "chat_id": chatID, + "stream_id": t.StreamID, + "position": i, + }) + } + // The task is already finished; its context was already canceled + // by removeTask(), so no further action is required. + continue + } else if !absoluteExpired && !graceExpired { filtered = append(filtered, t) - } else if !t.Finished { + } else { t.cancel() // cancel any lingering agent goroutine } } diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go index b1b5399f4..6510e6f81 100644 --- a/pkg/channels/wecom/common.go +++ b/pkg/channels/wecom/common.go @@ -1,12 +1,15 @@ package wecom import ( + "bytes" "crypto/aes" "crypto/cipher" + "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/binary" "fmt" + "math/big" "sort" "strings" ) @@ -51,64 +54,128 @@ func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (s return string(decoded), nil } - // Decode AES key (base64) - aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + aesKey, err := decodeWeComAESKey(encodingAESKey) if err != nil { - return "", fmt.Errorf("failed to decode AES key: %w", err) + return "", err } - // Decode encrypted message cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) if err != nil { return "", fmt.Errorf("failed to decode message: %w", err) } - // AES decrypt + plainText, err := decryptAESCBC(aesKey, cipherText) + if err != nil { + return "", err + } + + return unpackWeComFrame(plainText, receiveid) +} + +// decodeWeComAESKey base64-decodes the 43-character EncodingAESKey (trailing "=" is +// appended automatically) and validates that the result is exactly 32 bytes. +// It is the single place that handles this repeated pattern in both encrypt and decrypt paths. +func decodeWeComAESKey(encodingAESKey string) ([]byte, error) { + aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return nil, fmt.Errorf("failed to decode AES key: %w", err) + } + if len(aesKey) != 32 { + return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) + } + return aesKey, nil +} + +// encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring +// decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the +// plaintext to a multiple of aes.BlockSize before calling. +func encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(aesKey) if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) + return nil, fmt.Errorf("failed to create cipher: %w", err) } - - if len(cipherText) < aes.BlockSize { - return "", fmt.Errorf("ciphertext too short") - } - - // IV is the first 16 bytes of AESKey iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCDecrypter(block, iv) - plainText := make([]byte, len(cipherText)) - mode.CryptBlocks(plainText, cipherText) + ciphertext := make([]byte, len(plaintext)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext) + return ciphertext, nil +} - // Remove PKCS7 padding - plainText, err = pkcs7Unpad(plainText) - if err != nil { - return "", fmt.Errorf("failed to unpad: %w", err) +// packWeComFrame builds the WeCom wire format: +// +// random(16 ASCII digits) + msg_len(4, big-endian) + msg + receiveid +func packWeComFrame(msg, receiveid string) ([]byte, error) { + randomBytes := make([]byte, 16) + for i := range 16 { + n, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return nil, fmt.Errorf("failed to generate random: %w", err) + } + randomBytes[i] = byte('0' + n.Int64()) } + msgBytes := []byte(msg) + msgLenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(msgLenBytes, uint32(len(msgBytes))) + var buf bytes.Buffer + buf.Write(randomBytes) + buf.Write(msgLenBytes) + buf.Write(msgBytes) + buf.WriteString(receiveid) + return buf.Bytes(), nil +} - // Parse message structure - // Format: random(16) + msg_len(4) + msg + receiveid - if len(plainText) < 20 { - return "", fmt.Errorf("decrypted message too short") +// unpackWeComFrame parses the WeCom wire format produced by packWeComFrame. +// If receiveid is non-empty it verifies the frame's trailing receiveid field. +func unpackWeComFrame(data []byte, receiveid string) (string, error) { + if len(data) < 20 { + return "", fmt.Errorf("decrypted frame too short: %d bytes", len(data)) } - - msgLen := binary.BigEndian.Uint32(plainText[16:20]) - if int(msgLen) > len(plainText)-20 { - return "", fmt.Errorf("invalid message length") + msgLen := binary.BigEndian.Uint32(data[16:20]) + if int(msgLen) > len(data)-20 { + return "", fmt.Errorf("invalid message length: %d", msgLen) } - - msg := plainText[20 : 20+msgLen] - - // Verify receiveid if provided - if receiveid != "" && len(plainText) > 20+int(msgLen) { - actualReceiveID := string(plainText[20+msgLen:]) + msg := data[20 : 20+msgLen] + if receiveid != "" && len(data) > 20+int(msgLen) { + actualReceiveID := string(data[20+msgLen:]) if actualReceiveID != receiveid { return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) } } - return string(msg), nil } +// decryptAESCBC decrypts ciphertext using AES-CBC with the given key. +// IV = aesKey[:aes.BlockSize]. PKCS7 padding is stripped from the returned plaintext. +func decryptAESCBC(aesKey, ciphertext []byte) ([]byte, error) { + if len(ciphertext) == 0 { + return nil, fmt.Errorf("ciphertext is empty") + } + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext)) + } + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + iv := aesKey[:aes.BlockSize] + plaintext := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext) + plaintext, err = pkcs7Unpad(plaintext) + if err != nil { + return nil, fmt.Errorf("failed to unpad: %w", err) + } + return plaintext, nil +} + +// pkcs7Pad adds PKCS7 padding +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - (len(data) % blockSize) + if padding == 0 { + padding = blockSize + } + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + // pkcs7Unpad removes PKCS7 padding with validation func pkcs7Unpad(data []byte) ([]byte, error) { if len(data) == 0 { From 79bc06c0ba7caac36a872a5667185da7b5d8f92e Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:35:38 +0800 Subject: [PATCH 086/132] refactor(wecom): simplify stream message structure by introducing WeComAIBotMsgItem and WeComAIBotMsgItemImage types --- pkg/channels/wecom/aibot.go | 106 ++++++++---------------------------- 1 file changed, 23 insertions(+), 83 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 9003b0777..de56e7a75 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -98,18 +98,24 @@ type WeComAIBotMessage struct { } `json:"event,omitempty"` } -// WeComAIBotStreamInfo represents the detailed stream content in streaming responses +// WeComAIBotMsgItemImage holds the image payload inside a stream message item. +type WeComAIBotMsgItemImage struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` +} + +// WeComAIBotMsgItem is a single item inside a stream's msg_item list. +type WeComAIBotMsgItem struct { + MsgType string `json:"msgtype"` + Image *WeComAIBotMsgItemImage `json:"image,omitempty"` +} + +// WeComAIBotStreamInfo represents the detailed stream content in streaming responses. type WeComAIBotStreamInfo struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []WeComAIBotMsgItem `json:"msg_item,omitempty"` } // WeComAIBotStreamResponse represents the streaming response format @@ -457,18 +463,7 @@ func (c *WeComAIBotChannel) processMessage( }) return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: "Unsupported message type: " + msg.MsgType, @@ -586,18 +581,7 @@ func (c *WeComAIBotChannel) handleStreamMessage( ) return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: streamID, Finish: true, Content: "Task not found or already finished. Please resend your message to start a new session.", @@ -626,18 +610,7 @@ func (c *WeComAIBotChannel) handleImageMessage( // For now, just acknowledge receipt without echoing the image return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: fmt.Sprintf( @@ -657,18 +630,7 @@ func (c *WeComAIBotChannel) handleMixedMessage( logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented") return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: "Mixed message type is not yet supported", @@ -695,18 +657,7 @@ func (c *WeComAIBotChannel) handleEventMessage( streamID := c.generateStreamID() return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: streamID, Finish: true, Content: c.config.WelcomeMessage, @@ -765,18 +716,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce response := WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: task.StreamID, Finish: finish, Content: content, From 79b7fb7792fd8cce890c273a25b7c2a79735cb7b Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:43:33 +0800 Subject: [PATCH 087/132] fix(wecom): improve error handling in sendViaResponseURL and remove task on failure --- pkg/channels/wecom/aibot.go | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index de56e7a75..4bf29479c 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -242,6 +242,8 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e "error": err, "stream_id": task.StreamID, }) + c.removeTask(task) + return err } } else { logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ @@ -751,6 +753,8 @@ func (c *WeComAIBotChannel) removeTask(task *streamTask) { // sendViaResponseURL posts a markdown reply to the WeCom response_url. // response_url is valid for 1 hour and can only be used once per callback. +// Returned errors are wrapped with channels.ErrRateLimit, channels.ErrTemporary, +// or channels.ErrSendFailed so the manager can apply the right retry policy. func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error { payload := map[string]any{ "msgtype": "markdown", @@ -775,15 +779,26 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to post to response_url: %w", err) + return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("response_url returned %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode == http.StatusOK { + return nil + } + + respBody, _ := io.ReadAll(resp.Body) + switch { + case resp.StatusCode == http.StatusTooManyRequests: + return fmt.Errorf("response_url rate limited (%d): %s: %w", + resp.StatusCode, respBody, channels.ErrRateLimit) + case resp.StatusCode >= 500: + return fmt.Errorf("response_url server error (%d): %s: %w", + resp.StatusCode, respBody, channels.ErrTemporary) + default: + return fmt.Errorf("response_url returned %d: %s: %w", + resp.StatusCode, respBody, channels.ErrSendFailed) } - return nil } // encryptResponse encrypts a streaming response From 55c556a4c5cb9c57b2af54c4d4133563bc0b89a7 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:46:04 +0800 Subject: [PATCH 088/132] fix(wecom): update CanonicalID generation to use identity.BuildCanonicalID for consistency --- pkg/channels/wecom/aibot.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 4bf29479c..27c118675 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -17,6 +17,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -531,7 +532,7 @@ func (c *WeComAIBotChannel) handleTextMessage( sender := bus.SenderInfo{ Platform: "wecom_aibot", PlatformID: userID, - CanonicalID: "wecom_aibot:" + userID, + CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), DisplayName: userID, } peerKind := "direct" From d4824a00b6756c0bb67be670014d3cfb091fc5ef Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:50:18 +0800 Subject: [PATCH 089/132] refactor(config): remove WebhookHost and WebhookPort from WeComAIBotConfig --- pkg/config/config.go | 2 -- pkg/config/defaults.go | 2 -- 2 files changed, 4 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 439c2b995..51e55a99a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -365,8 +365,6 @@ type WeComAIBotConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index fce955c83..fb0fd4451 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -141,8 +141,6 @@ func DefaultConfig() *Config { Enabled: false, Token: "", EncodingAESKey: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18791, WebhookPath: "/webhook/wecom-aibot", AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, From bf4445f1f333194c4b8381752f13fbef273efd9e Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:52:56 +0800 Subject: [PATCH 090/132] refactor(docs): remove webhook_host and webhook_port from configuration examples --- README.fr.md | 2 -- README.ja.md | 2 -- README.md | 2 -- README.pt-br.md | 2 -- README.vi.md | 2 -- config/config.example.json | 2 -- docs/channels/wecom/wecom_aibot/README.zh.md | 4 ---- 7 files changed, 16 deletions(-) diff --git a/README.fr.md b/README.fr.md index 43a6cab7a..e537fc13a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -581,8 +581,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Bonjour ! Comment puis-je vous aider ?" diff --git a/README.ja.md b/README.ja.md index 34c034243..20ad5033b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -548,8 +548,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "こんにちは!何かお手伝いできますか?" diff --git a/README.md b/README.md index 046213598..a06f2ea61 100644 --- a/README.md +++ b/README.md @@ -649,8 +649,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Hello! How can I help you?" diff --git a/README.pt-br.md b/README.pt-br.md index c37fb929b..bfe655770 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -577,8 +577,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Olá! Como posso ajudá-lo?" diff --git a/README.vi.md b/README.vi.md index 417ca0393..b30659614 100644 --- a/README.vi.md +++ b/README.vi.md @@ -549,8 +549,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?" diff --git a/config/config.example.json b/config/config.example.json index 872358bd4..e292731b9 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -155,8 +155,6 @@ "enabled": false, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "max_steps": 10, "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md index 8470fe16f..d210528af 100644 --- a/docs/channels/wecom/wecom_aibot/README.zh.md +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -21,8 +21,6 @@ "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "你好!有什么可以帮助你的吗?", @@ -36,8 +34,6 @@ | ---------------- | ------ | ---- | -------------------------------------------------- | | token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | | encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | -| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) | -| webhook_port | int | 否 | HTTP 服务器端口(默认:18791) | | webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) | | allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | | welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | From 619948f8ff59a30c4c8ef91bd3ece7d7bc051b80 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sun, 1 Mar 2026 00:00:14 +0800 Subject: [PATCH 091/132] fix(wecom): improve error message for response_url delivery failure --- pkg/channels/wecom/aibot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 27c118675..e9eda6810 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -244,7 +244,7 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e "stream_id": task.StreamID, }) c.removeTask(task) - return err + return fmt.Errorf("response_url delivery failed: %w", channels.ErrSendFailed) } } else { logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ From edd339e05603d187db0e7bad65462b393aa3d660 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sun, 1 Mar 2026 00:02:22 +0800 Subject: [PATCH 092/132] fix(wecom): handle empty response by encrypting and returning a default response --- pkg/channels/wecom/aibot.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index e9eda6810..4bd8adb98 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -433,6 +433,11 @@ func (c *WeComAIBotChannel) handleMessageCallback( // Process the message and get streaming response response := c.processMessage(ctx, msg, timestamp, nonce) + // Check if response is empty (e.g. due to unsupported message type) + if response == "" { + response = c.encryptEmptyResponse(timestamp, nonce) + } + // Return encrypted JSON response w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) From 18b36af9342b648b0dd1e2ba6d9f56866af76744 Mon Sep 17 00:00:00 2001 From: shikihane Date: Mon, 2 Mar 2026 18:08:32 +0800 Subject: [PATCH 093/132] 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 23f48d7c4e76c3b4bda281b5f2bc4bce3af364ee Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Mon, 2 Mar 2026 18:21:53 +0800 Subject: [PATCH 094/132] refactor(aibot): remove downloadAndDecryptImage function to streamline image handling --- pkg/channels/wecom/aibot.go | 58 ------------------------------------- 1 file changed, 58 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 4bd8adb98..6c5aca40b 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -903,64 +903,6 @@ func (c *WeComAIBotChannel) generateStreamID() string { return string(b) } -// downloadAndDecryptImage downloads and decrypts an encrypted image -func (c *WeComAIBotChannel) downloadAndDecryptImage( - ctx context.Context, - imageURL string, -) ([]byte, error) { - // Download image - req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - client := &http.Client{ - Timeout: 15 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to download image: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) - } - - // Limit image download to 20 MB to prevent memory exhaustion - const maxImageSize = 20 << 20 // 20 MB - encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1)) - if err != nil { - return nil, fmt.Errorf("failed to read image data: %w", err) - } - if len(encryptedData) > maxImageSize { - return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20) - } - - logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ - "size": len(encryptedData), - }) - - // Decode AES key - aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) - if err != nil { - return nil, err - } - - // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped) - decryptedData, err := decryptAESCBC(aesKey, encryptedData) - if err != nil { - return nil, fmt.Errorf("failed to decrypt image: %w", err) - } - - logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ - "size": len(decryptedData), - }) - - return decryptedData, nil -} - // cleanupLoop periodically cleans up old streaming tasks func (c *WeComAIBotChannel) cleanupLoop() { ticker := time.NewTicker(5 * time.Minute) From 5fa2e1d1e4f7372b5fbb4c79c215728778531bce Mon Sep 17 00:00:00 2001 From: lxowalle Date: Mon, 2 Mar 2026 21:17:59 +0800 Subject: [PATCH 095/132] * update contributing.md --- CONTRIBUTING.md | 2 +- CONTRIBUTING.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88227f493..ea465aab3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -269,7 +269,7 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in |Function| Reviewer| |--- |--- | |Provider|@yinwm | -|Channel |@yinwm | +|Channel |@yinwm/@alexhoshina | |Agent |@lxowalle| |Tools |@lxowalle| |SKill || diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md index 01a1abfd5..46285a7c3 100644 --- a/CONTRIBUTING.zh.md +++ b/CONTRIBUTING.zh.md @@ -268,7 +268,7 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直 |Function| Reviewer| |--- |--- | |Provider|@yinwm | -|Channel |@yinwm | +|Channel |@yinwm/@alexhoshina | |Agent |@lxowalle| |Tools |@lxowalle| |SKill || From 4402fcf63c949e403c2d0a00df7ed3a308d9266b Mon Sep 17 00:00:00 2001 From: lxowalle Date: Mon, 2 Mar 2026 21:20:23 +0800 Subject: [PATCH 096/132] * update contributing.md --- CONTRIBUTING.md | 2 +- CONTRIBUTING.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea465aab3..ceff723d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -270,7 +270,7 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in |--- |--- | |Provider|@yinwm | |Channel |@yinwm/@alexhoshina | -|Agent |@lxowalle| +|Agent |@lxowalle/@Zhaoyikaiii| |Tools |@lxowalle| |SKill || |MCP || diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md index 46285a7c3..196aecc65 100644 --- a/CONTRIBUTING.zh.md +++ b/CONTRIBUTING.zh.md @@ -269,7 +269,7 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直 |--- |--- | |Provider|@yinwm | |Channel |@yinwm/@alexhoshina | -|Agent |@lxowalle| +|Agent |@lxowalle/@Zhaoyikaiii| |Tools |@lxowalle| |SKill || |MCP || From 18d89937ad487bd467b64aa798e5ec4560adecb7 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Tue, 24 Feb 2026 14:29:17 +0300 Subject: [PATCH 097/132] 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 098/132] 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 099/132] 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 100/132] 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 101/132] 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 102/132] 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 103/132] 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 104/132] 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 105/132] 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 106/132] 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 107/132] 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 108/132] 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 109/132] 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 110/132] 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 111/132] 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 112/132] * 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 113/132] 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 114/132] 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 115/132] 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 116/132] 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 117/132] 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 118/132] 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 119/132] 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 120/132] 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 121/132] 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 122/132] 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 123/132] 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) +} From 16209d1da926802f522ec7a5353bef6ba6156fd4 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Tue, 3 Mar 2026 18:45:25 -0800 Subject: [PATCH 124/132] docs: update wechat qrcode --- assets/wechat.png | Bin 143484 -> 98050 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/wechat.png b/assets/wechat.png index 1c0b88295e1d34de1a67757e6d7f8067cfa3337a..32998c1220f9c1e7228420f2f83ec32f349b5f1d 100644 GIT binary patch literal 98050 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

R%GH)5PfUfyhF!yL=wBUz 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"v;8)0M$b<$iR`A^5dd~XE(Hr#2IZOziK2f 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%0Dh1<4h~eL7a%QmtW#e7t z34AeOX595E{i$XCf#2zA%bY|yUa1XKy9G8-xt<)`jF=kz)F`(1u{I?quZO1S)ZN_c zHJC6SEep@*KKani=2d-hqm)R}d(iYionjVBYZ{e$*VvWt%%>pXrR({z|Hdy;pK;C^ zIBjM29BT^O&b8VE{mpWmOrl2$ymH#+)#lKF;k%5Hg$C?ws+WBal}|~|45c;~yThJA zzqgBN3A^4^TqeFLk{s75(a={nYz6W$#;RxPHj4|YO0aqNl$@!iX}*MA)eO&xskVL_ z82Xh(v7SVJ&#SKfLOpN&1%n97z*7l4waY5sN{XZKN@9n`!Rci_Mp={-w_P8QVeqRe z{;}gWobS|_|MtVF&5GUPZpNjN?UHfMMhbQ}tC+`xe!Wq0Qc?fltrTeNkc0wh*>ra| z7*&;EKI^~#l0C6mKzJqJ+XwP1R;2^KHdH12=Ey>o>(61-&R>AqSMgD+%D1pLgUS=3 z<-AZO=VJ%R%Lm#*I}6)NkMIL^iX0lHp^ zi2K>r5E>gKaDv^0mr{9S?h{N+=L)S6={A^GMMkusUnIj^RR@Z5))ZuQlxD_VBu;G2 z@J}d^4$8QmO{)C+AF(r*y|)h&X-2lFk^UD@GlPmIE>tvO0MIZ|KMbsYM-v7aCKfr9 zfFS!6-QKAj%EKD_Yr>qUo1dDt2=i>2VvH?HxfiU)I*od6)(!8 zTS7QD^WH~x7*7ARR%bp|2_d$I{i%yr_NHmuOUfnBP7SB_1P{8TQx+cys}SK!fe`av zPV;}Nc$|25WZxiuUGDE*bd=g}G2$>5ny^uWQ)UX)1ETA`&&6I?@YFk|ieD4k(rWZ4 zB(}Z$v0HV#+ql^T`II(Zdi|+c$_6F#J0Ws>Rub3HHTE`n$rmrgD-b)R+ZDzI`?ik z>nK;zB^^>^otSZ`G{WcR;_hRc>Um0>fy}NBk225Mr?iLmjBqz=UtPTtFOJf7uP4AF z!MyqO+OQS1uEO-ygGyogQnH#1Ms711SjlI-JbZZsHU8u|t7$Q8HKcR4%t@iy#5YH& z?{q&R>j{0PF3K>YM`e5_K&`lYCFakw?ZGvpX!Iw5e1@Bj<+oaL(Z-jAh1D)y2H^JddL zD`EKEA1uwDWteg|gc#b{r9sp=^QDQAS7G*}&ZMfwh%j>+Thuqo80HZUYHs`AyOdJc z2hoTT0aXHq+|J>HdFq)}ES?LKiF;E5mT#<&AI6y7nXb*LPhVi4c<@LCf#PT7KHY^O z>=6D6CGKt7l>uz?OyX++aTKuCnevP9&sM$7B7a^3-?CEiIdrcV3JxZ{-d&CIEt%YN zEJ`Cjumc~jaM}F@ti2nA9x|N~3D2pc9(@Mv_*>uSKX1vWlDaduB5ADt&j|Y281#4f Fe*iCX!zTa$ literal 143484 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

kDnrJxtp)0E|3s*Tay_g_^Sqg*Vvp@9y=E# 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 From df1b53fdf9baff1460e0a9dda72e5c9c775d3eff Mon Sep 17 00:00:00 2001 From: Rahul Bansal <42.rahulbansal@gmail.com> Date: Wed, 4 Mar 2026 08:53:01 +0530 Subject: [PATCH 125/132] feat: make summarization message threshold and token percent configurable (#854) (#1029) Co-authored-by: Rahul Bansal --- pkg/agent/instance.go | 78 +++++++++++++++++++++++---------------- pkg/agent/loop.go | 4 +- pkg/config/config.go | 2 + pkg/config/config_test.go | 12 ++++++ pkg/config/defaults.go | 16 ++++---- 5 files changed, 71 insertions(+), 41 deletions(-) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index ed438059f..ed25f537f 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -18,22 +18,24 @@ import ( // AgentInstance represents a fully configured agent with its own workspace, // session manager, context builder, and tool registry. type AgentInstance struct { - ID string - Name string - Model string - Fallbacks []string - Workspace string - MaxIterations int - MaxTokens int - Temperature float64 - ContextWindow int - Provider providers.LLMProvider - Sessions *session.SessionManager - ContextBuilder *ContextBuilder - Tools *tools.ToolRegistry - Subagents *config.SubagentsConfig - SkillsFilter []string - Candidates []providers.FallbackCandidate + ID string + Name string + Model string + Fallbacks []string + Workspace string + MaxIterations int + MaxTokens int + Temperature float64 + ContextWindow int + SummarizeMessageThreshold int + SummarizeTokenPercent int + Provider providers.LLMProvider + Sessions *session.SessionManager + ContextBuilder *ContextBuilder + Tools *tools.ToolRegistry + Subagents *config.SubagentsConfig + SkillsFilter []string + Candidates []providers.FallbackCandidate } // NewAgentInstance creates an agent instance from config. @@ -101,6 +103,16 @@ func NewAgentInstance( temperature = *defaults.Temperature } + summarizeMessageThreshold := defaults.SummarizeMessageThreshold + if summarizeMessageThreshold == 0 { + summarizeMessageThreshold = 20 + } + + summarizeTokenPercent := defaults.SummarizeTokenPercent + if summarizeTokenPercent == 0 { + summarizeTokenPercent = 75 + } + // Resolve fallback candidates modelCfg := providers.ModelConfig{ Primary: model, @@ -149,22 +161,24 @@ func NewAgentInstance( candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) return &AgentInstance{ - ID: agentID, - Name: agentName, - Model: model, - Fallbacks: fallbacks, - Workspace: workspace, - MaxIterations: maxIter, - MaxTokens: maxTokens, - Temperature: temperature, - ContextWindow: maxTokens, - Provider: provider, - Sessions: sessionsManager, - ContextBuilder: contextBuilder, - Tools: toolsRegistry, - Subagents: subagents, - SkillsFilter: skillsFilter, - Candidates: candidates, + ID: agentID, + Name: agentName, + Model: model, + Fallbacks: fallbacks, + Workspace: workspace, + MaxIterations: maxIter, + MaxTokens: maxTokens, + Temperature: temperature, + ContextWindow: maxTokens, + SummarizeMessageThreshold: summarizeMessageThreshold, + SummarizeTokenPercent: summarizeTokenPercent, + Provider: provider, + Sessions: sessionsManager, + ContextBuilder: contextBuilder, + Tools: toolsRegistry, + Subagents: subagents, + SkillsFilter: skillsFilter, + Candidates: candidates, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b803187b1..da43bf177 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1081,9 +1081,9 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) - threshold := agent.ContextWindow * 75 / 100 + threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 - if len(newHistory) > 20 || tokenEstimate > threshold { + if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { summarizeKey := agent.ID + ":" + sessionKey if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { go func() { diff --git a/pkg/config/config.go b/pkg/config/config.go index cb2799bba..78114648c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -180,6 +180,8 @@ 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"` + SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` + SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 6af7c209e..10ebc7c90 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -435,6 +435,18 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) { } // TestDefaultConfig_DMScope verifies the default dm_scope value +// TestDefaultConfig_SummarizationThresholds verifies summarization defaults +func TestDefaultConfig_SummarizationThresholds(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 { + t.Errorf("SummarizeMessageThreshold = %d, want 20", cfg.Agents.Defaults.SummarizeMessageThreshold) + } + if cfg.Agents.Defaults.SummarizeTokenPercent != 75 { + t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent) + } +} + func TestDefaultConfig_DMScope(t *testing.T) { cfg := DefaultConfig() diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9fc09c5f1..70d3e5985 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -26,13 +26,15 @@ func DefaultConfig() *Config { return &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ - Workspace: workspacePath, - RestrictToWorkspace: true, - Provider: "", - Model: "", - MaxTokens: 32768, - Temperature: nil, // nil means use provider default - MaxToolIterations: 50, + Workspace: workspacePath, + RestrictToWorkspace: true, + Provider: "", + Model: "", + MaxTokens: 32768, + Temperature: nil, // nil means use provider default + MaxToolIterations: 50, + SummarizeMessageThreshold: 20, + SummarizeTokenPercent: 75, }, }, Bindings: []AgentBinding{}, From b82bb9acc0b9a53b9a14c4d9a916778f9675b257 Mon Sep 17 00:00:00 2001 From: shikihane <48197860+shikihane@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:58:12 +0800 Subject: [PATCH 126/132] =?UTF-8?q?feat(tools):=20add=20GLM=20Search=20(?= =?UTF-8?q?=E6=99=BA=E8=B0=B1)=20web=20search=20provider=20(#1057)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add GLMSearchConfig for GLM Search provider Co-Authored-By: Claude Opus 4.6 * test(tools): add failing tests for GLM Search provider Co-Authored-By: Claude Opus 4.6 * feat(tools): add GLMSearchProvider for web search Co-Authored-By: Claude Opus 4.6 * feat(agent): wire GLM Search config into web search tool registration Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- pkg/agent/loop.go | 5 ++ pkg/config/config.go | 11 ++++ pkg/config/defaults.go | 7 +++ pkg/tools/web.go | 108 ++++++++++++++++++++++++++++++++- pkg/tools/web_test.go | 132 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index da43bf177..ef7ded721 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -118,6 +118,11 @@ func registerSharedTools( PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey, + GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, + GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, Proxy: cfg.Tools.Web.Proxy, }) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 78114648c..f40e05e1c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -547,11 +547,22 @@ type PerplexityConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } +type GLMSearchConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` + // SearchEngine specifies the search backend: "search_std" (default), + // "search_pro", "search_pro_sogou", or "search_pro_quark". + SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` +} + type WebToolsConfig struct { Brave BraveConfig `json:"brave"` Tavily TavilyConfig `json:"tavily"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` Perplexity PerplexityConfig `json:"perplexity"` + GLMSearch GLMSearchConfig `json:"glm_search"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 70d3e5985..6f65dd469 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -343,6 +343,13 @@ func DefaultConfig() *Config { APIKey: "", MaxResults: 5, }, + GLMSearch: GLMSearchConfig{ + Enabled: false, + APIKey: "", + BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search", + SearchEngine: "search_std", + MaxResults: 5, + }, }, Cron: CronToolsConfig{ ExecTimeoutMinutes: 5, diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 15d2330ff..7b14686c9 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -395,6 +395,88 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil } +type GLMSearchProvider struct { + apiKey string + baseURL string + searchEngine string + proxy string + client *http.Client +} + +func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" + } + + payload := map[string]any{ + "search_query": query, + "search_engine": p.searchEngine, + "search_intent": false, + "count": count, + "content_size": "medium", + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GLM Search API error (status %d): %s", resp.StatusCode, string(body)) + } + + var searchResp struct { + SearchResult []struct { + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` + } `json:"search_result"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.SearchResult + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via GLM Search)", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.Link)) + if item.Content != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Content)) + } + } + + return strings.Join(lines, "\n"), nil +} + type WebSearchTool struct { provider SearchProvider maxResults int @@ -413,6 +495,11 @@ type WebSearchToolOptions struct { PerplexityAPIKey string PerplexityMaxResults int PerplexityEnabled bool + GLMSearchAPIKey string + GLMSearchBaseURL string + GLMSearchEngine string + GLMSearchMaxResults int + GLMSearchEnabled bool Proxy string } @@ -420,7 +507,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider maxResults := 5 - // Priority: Perplexity > Brave > Tavily > DuckDuckGo + // Priority: Perplexity > Brave > Tavily > DuckDuckGo > GLM Search if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { client, err := createHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { @@ -462,6 +549,25 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.DuckDuckGoMaxResults > 0 { maxResults = opts.DuckDuckGoMaxResults } + } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" { + client, err := createHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) + } + searchEngine := opts.GLMSearchEngine + if searchEngine == "" { + searchEngine = "search_std" + } + provider = &GLMSearchProvider{ + apiKey: opts.GLMSearchAPIKey, + baseURL: opts.GLMSearchBaseURL, + searchEngine: searchEngine, + proxy: opts.Proxy, + client: client, + } + if opts.GLMSearchMaxResults > 0 { + maxResults = opts.GLMSearchMaxResults + } } else { return nil, nil } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 8a8b88131..bdd30d385 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -681,3 +681,135 @@ func TestWebTool_TavilySearch_Success(t *testing.T) { t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) } } + +func TestWebTool_GLMSearch_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + if r.Header.Get("Authorization") != "Bearer test-glm-key" { + t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization")) + } + + var payload map[string]any + json.NewDecoder(r.Body).Decode(&payload) + if payload["search_query"] != "test query" { + t.Errorf("Expected search_query 'test query', got %v", payload["search_query"]) + } + if payload["search_engine"] != "search_std" { + t.Errorf("Expected search_engine 'search_std', got %v", payload["search_engine"]) + } + + response := map[string]any{ + "id": "web-search-test", + "created": 1709568000, + "search_result": []map[string]any{ + { + "title": "Test GLM Result", + "content": "GLM search snippet", + "link": "https://example.com/glm", + "media": "Example", + "publish_date": "2026-03-04", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-glm-key", + GLMSearchBaseURL: server.URL, + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + }) + + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + if !strings.Contains(result.ForUser, "Test GLM Result") { + t.Errorf("Expected 'Test GLM Result' in output, got: %s", result.ForUser) + } + if !strings.Contains(result.ForUser, "https://example.com/glm") { + t.Errorf("Expected URL in output, got: %s", result.ForUser) + } + if !strings.Contains(result.ForUser, "via GLM Search") { + t.Errorf("Expected 'via GLM Search' in output, got: %s", result.ForUser) + } +} + +func TestWebTool_GLMSearch_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"invalid api key"}`)) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + GLMSearchEnabled: true, + GLMSearchAPIKey: "bad-key", + GLMSearchBaseURL: server.URL, + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + }) + + if !result.IsError { + t.Errorf("Expected IsError=true for 401 response") + } + if !strings.Contains(result.ForLLM, "status 401") { + t.Errorf("Expected status 401 in error, got: %s", result.ForLLM) + } +} + +func TestWebTool_GLMSearch_Priority(t *testing.T) { + // GLM Search should only be selected when all other providers are disabled + tool, err := NewWebSearchTool(WebSearchToolOptions{ + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 5, + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-key", + GLMSearchBaseURL: "https://example.com", + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + // DuckDuckGo should win over GLM Search + if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok { + t.Errorf("Expected DuckDuckGoSearchProvider when both enabled, got %T", tool.provider) + } + + // With DuckDuckGo disabled, GLM Search should be selected + tool2, err := NewWebSearchTool(WebSearchToolOptions{ + DuckDuckGoEnabled: false, + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-key", + GLMSearchBaseURL: "https://example.com", + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool2.provider.(*GLMSearchProvider); !ok { + t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider) + } +} From 2a577f7a1d2aecb2eb50a0fb67cd95311b8891bf Mon Sep 17 00:00:00 2001 From: Meng Zhuo Date: Wed, 4 Mar 2026 17:05:57 +0800 Subject: [PATCH 127/132] chore: alter env timezone from Asia/Tokyo to Asia/Shanghai (#1054) --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 06d43070c..bc68456d6 100644 --- a/.env.example +++ b/.env.example @@ -17,4 +17,4 @@ # BRAVE_SEARCH_API_KEY=BSA... # ── Timezone ────────────────────────────── -TZ=Asia/Tokyo +TZ=Asia/Shanghai From 028605cfd09d78c3db1c9f9fae65538f442a7c34 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:17:28 +0800 Subject: [PATCH 128/132] feat: execute LLM tool calls in parallel for faster response (#1070) When the LLM returns multiple tool calls, they are now executed concurrently using goroutines + sync.WaitGroup instead of sequentially. Results are collected in an indexed slice and processed in original order to preserve message ordering. MessageTool.sentInRound is changed to atomic.Bool for thread safety. Co-authored-by: Claude Opus 4.6 --- pkg/agent/loop.go | 102 ++++++++++++++++++++++++------------------ pkg/tools/message.go | 9 ++-- pkg/tools/toolloop.go | 69 +++++++++++++++++----------- 3 files changed, 106 insertions(+), 74 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ef7ded721..db9efa2cf 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -969,62 +969,76 @@ func (al *AgentLoop) runLLMIteration( // Save assistant message with tool calls to session agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) - // Execute tool calls - for _, tc := range normalizedToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - map[string]any{ - "agent_id": agent.ID, - "tool": tc.Name, - "iteration": iteration, - }) + // Execute tool calls in parallel + type indexedAgentResult struct { + result *tools.ToolResult + tc providers.ToolCall + } - // Create async callback for tools that implement AsyncTool - // NOTE: Following openclaw's design, async tools do NOT send results directly to users. - // Instead, they notify the agent via PublishInbound, and the agent decides - // whether to forward the result to the user (in processSystemMessage). - asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) { - // Log the async completion but don't send directly to user - // The agent will handle user notification via processSystemMessage - if !result.Silent && result.ForUser != "" { - logger.InfoCF("agent", "Async tool completed, agent will handle notification", - map[string]any{ - "tool": tc.Name, - "content_len": len(result.ForUser), - }) + agentResults := make([]indexedAgentResult, len(normalizedToolCalls)) + var wg sync.WaitGroup + + for i, tc := range normalizedToolCalls { + agentResults[i].tc = tc + + wg.Add(1) + go func(idx int, tc providers.ToolCall) { + defer wg.Done() + + argsJSON, _ := json.Marshal(tc.Arguments) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), + map[string]any{ + "agent_id": agent.ID, + "tool": tc.Name, + "iteration": iteration, + }) + + // Create async callback for tools that implement AsyncTool + asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) { + if !result.Silent && result.ForUser != "" { + logger.InfoCF("agent", "Async tool completed, agent will handle notification", + map[string]any{ + "tool": tc.Name, + "content_len": len(result.ForUser), + }) + } } - } - toolResult := agent.Tools.ExecuteWithContext( - ctx, - tc.Name, - tc.Arguments, - opts.Channel, - opts.ChatID, - asyncCallback, - ) + toolResult := agent.Tools.ExecuteWithContext( + ctx, + tc.Name, + tc.Arguments, + opts.Channel, + opts.ChatID, + asyncCallback, + ) + agentResults[idx].result = toolResult + }(i, tc) + } + wg.Wait() + // Process results in original order (send to user, save to session) + for _, r := range agentResults { // Send ForUser content to user immediately if not Silent - if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { + if !r.result.Silent && r.result.ForUser != "" && opts.SendResponse { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, - Content: toolResult.ForUser, + Content: r.result.ForUser, }) logger.DebugCF("agent", "Sent tool result to user", map[string]any{ - "tool": tc.Name, - "content_len": len(toolResult.ForUser), + "tool": r.tc.Name, + "content_len": len(r.result.ForUser), }) } // If tool returned media refs, publish them as outbound media - if len(toolResult.Media) > 0 && opts.SendResponse { - parts := make([]bus.MediaPart, 0, len(toolResult.Media)) - for _, ref := range toolResult.Media { + if len(r.result.Media) > 0 && opts.SendResponse { + parts := make([]bus.MediaPart, 0, len(r.result.Media)) + for _, ref := range r.result.Media { part := bus.MediaPart{Ref: ref} - // Populate metadata from MediaStore when available if al.mediaStore != nil { if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { part.Filename = meta.Filename @@ -1042,15 +1056,15 @@ func (al *AgentLoop) runLLMIteration( } // Determine content for LLM based on tool result - contentForLLM := toolResult.ForLLM - if contentForLLM == "" && toolResult.Err != nil { - contentForLLM = toolResult.Err.Error() + contentForLLM := r.result.ForLLM + if contentForLLM == "" && r.result.Err != nil { + contentForLLM = r.result.Err.Error() } toolResultMsg := providers.Message{ Role: "tool", Content: contentForLLM, - ToolCallID: tc.ID, + ToolCallID: r.tc.ID, } messages = append(messages, toolResultMsg) diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 15ef4ff73..d1e4a373e 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "sync/atomic" ) type SendCallback func(channel, chatID, content string) error @@ -11,7 +12,7 @@ type MessageTool struct { sendCallback SendCallback defaultChannel string defaultChatID string - sentInRound bool // Tracks whether a message was sent in the current processing round + sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round } func NewMessageTool() *MessageTool { @@ -50,12 +51,12 @@ func (t *MessageTool) Parameters() map[string]any { func (t *MessageTool) SetContext(channel, chatID string) { t.defaultChannel = channel t.defaultChatID = chatID - t.sentInRound = false // Reset send tracking for new processing round + t.sentInRound.Store(false) // Reset send tracking for new processing round } // HasSentInRound returns true if the message tool sent a message during the current round. func (t *MessageTool) HasSentInRound() bool { - return t.sentInRound + return t.sentInRound.Load() } func (t *MessageTool) SetSendCallback(callback SendCallback) { @@ -94,7 +95,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes } } - t.sentInRound = true + t.sentInRound.Store(true) // Silent: user already received the message directly return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index cdfe0d6ce..244f0d4a2 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "sync" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -121,37 +122,53 @@ func RunToolLoop( } messages = append(messages, assistantMsg) - // 7. Execute tool calls - for _, tc := range normalizedToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - map[string]any{ - "tool": tc.Name, - "iteration": iteration, - }) + // 7. Execute tool calls in parallel + type indexedResult struct { + result *ToolResult + tc providers.ToolCall + } - // Execute tool (no async callback for subagents - they run independently) - var toolResult *ToolResult - if config.Tools != nil { - toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) - } else { - toolResult = ErrorResult("No tools available") + results := make([]indexedResult, len(normalizedToolCalls)) + var wg sync.WaitGroup + + for i, tc := range normalizedToolCalls { + results[i].tc = tc + + wg.Add(1) + go func(idx int, tc providers.ToolCall) { + defer wg.Done() + + argsJSON, _ := json.Marshal(tc.Arguments) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), + map[string]any{ + "tool": tc.Name, + "iteration": iteration, + }) + + var toolResult *ToolResult + if config.Tools != nil { + toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) + } else { + toolResult = ErrorResult("No tools available") + } + results[idx].result = toolResult + }(i, tc) + } + wg.Wait() + + // Append results in original order + for _, r := range results { + contentForLLM := r.result.ForLLM + if contentForLLM == "" && r.result.Err != nil { + contentForLLM = r.result.Err.Error() } - // Determine content for LLM - contentForLLM := toolResult.ForLLM - if contentForLLM == "" && toolResult.Err != nil { - contentForLLM = toolResult.Err.Error() - } - - // Add tool result message - toolResultMsg := providers.Message{ + messages = append(messages, providers.Message{ Role: "tool", Content: contentForLLM, - ToolCallID: tc.ID, - } - messages = append(messages, toolResultMsg) + ToolCallID: r.tc.ID, + }) } } From 494953fb780e86d82535fc475ad746ac9cf83fb7 Mon Sep 17 00:00:00 2001 From: Dimitrij Denissenko Date: Wed, 4 Mar 2026 10:21:59 +0000 Subject: [PATCH 129/132] Fix lint --- pkg/agent/loop.go | 3 +-- pkg/voice/transcriber_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f37d419b1..36b4f5546 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -12,14 +12,13 @@ import ( "errors" "fmt" "path/filepath" + "regexp" "strings" "sync" "sync/atomic" "time" "unicode/utf8" - "regexp" - "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go index 6a28b3664..9b6add333 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/voice/transcriber_test.go @@ -24,10 +24,10 @@ func TestGroqTranscriberName(t *testing.T) { func TestDetectTranscriber(t *testing.T) { tests := []struct { - name string - cfg *config.Config - wantNil bool - wantName string + name string + cfg *config.Config + wantNil bool + wantName string }{ { name: "no config", From f9f726c0c1c58aeac51f7ea4cbc138720bd37c31 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 4 Mar 2026 19:21:34 +0800 Subject: [PATCH 130/132] fix(memory): fsync appended message for consistent durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addMsg now calls f.Sync() before f.Close(), matching the durability guarantee of writeMeta and rewriteJSONL (both use WriteFileAtomic with fsync). Without this, a power loss could leave the appended line in the kernel page cache only — lost on reboot. --- pkg/memory/jsonl.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index efd4347c0..e12e2c5ab 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -236,11 +236,19 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { return fmt.Errorf("memory: open jsonl for append: %w", err) } _, writeErr := f.Write(line) - closeErr := f.Close() if writeErr != nil { + f.Close() return fmt.Errorf("memory: append message: %w", writeErr) } - if closeErr != nil { + // Flush to physical storage before closing. This matches the + // durability guarantee of writeMeta and rewriteJSONL (which use + // WriteFileAtomic with fsync). Without Sync, a power loss could + // leave the append in the kernel page cache only — lost on reboot. + if syncErr := f.Sync(); syncErr != nil { + f.Close() + return fmt.Errorf("memory: sync jsonl: %w", syncErr) + } + if closeErr := f.Close(); closeErr != nil { return fmt.Errorf("memory: close jsonl: %w", closeErr) } From 93689b82314a32a01fe6ffc431eca54affc2cf39 Mon Sep 17 00:00:00 2001 From: rankaiyx Date: Wed, 4 Mar 2026 20:16:16 +0800 Subject: [PATCH 131/132] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c5b38e222..759ebbb82 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ ## 📢 News -2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board! +2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board! 2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. 🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. From b3946984ada6c7c158efee60341b9692bc712cca Mon Sep 17 00:00:00 2001 From: Oceanpie Date: Wed, 4 Mar 2026 21:55:02 +0800 Subject: [PATCH 132/132] docs(config): expose summarization thresholds in config example --- config/config.example.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index adae6f05c..f46f6a670 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -6,7 +6,9 @@ "model_name": "gpt4", "max_tokens": 8192, "temperature": 0.7, - "max_tool_iterations": 20 + "max_tool_iterations": 20, + "summarize_message_threshold": 20, + "summarize_token_percent": 75 } }, "model_list": [ @@ -338,4 +340,4 @@ "host": "127.0.0.1", "port": 18790 } -} \ No newline at end of file +}